mppx 0.5.7 → 0.5.8
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 +8 -0
- package/dist/Challenge.d.ts +3 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +27 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +32 -14
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/Store.d.ts +68 -2
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +41 -4
- package/dist/Store.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +7 -0
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +133 -70
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +8 -2
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +26 -1
- package/dist/server/Transport.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +13 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +429 -4
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +28 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +89 -0
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +4 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +90 -66
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +3 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +8 -2
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.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/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -6
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +55 -14
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +11 -2
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +66 -25
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Ws.d.ts +87 -0
- package/dist/tempo/session/Ws.d.ts.map +1 -0
- package/dist/tempo/session/Ws.js +428 -0
- package/dist/tempo/session/Ws.js.map +1 -0
- package/dist/tempo/session/index.d.ts +1 -0
- package/dist/tempo/session/index.d.ts.map +1 -1
- package/dist/tempo/session/index.js +1 -0
- package/dist/tempo/session/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Challenge.ts +28 -9
- package/src/Method.ts +61 -20
- package/src/Store.test-d.ts +80 -2
- package/src/Store.test.ts +150 -13
- package/src/Store.ts +140 -3
- package/src/mcp-sdk/server/Transport.test.ts +12 -0
- package/src/mcp-sdk/server/Transport.ts +8 -0
- package/src/server/Mppx.test.ts +105 -0
- package/src/server/Mppx.ts +178 -88
- package/src/server/Transport.test.ts +31 -0
- package/src/server/Transport.ts +31 -2
- package/src/tempo/client/SessionManager.ts +510 -7
- package/src/tempo/internal/fee-payer.test.ts +115 -1
- package/src/tempo/internal/fee-payer.ts +138 -1
- package/src/tempo/server/AtomicStore.test-d.ts +34 -0
- package/src/tempo/server/Charge.test.ts +128 -0
- package/src/tempo/server/Charge.ts +118 -93
- package/src/tempo/server/Methods.ts +3 -0
- package/src/tempo/server/Session.test.ts +1044 -47
- package/src/tempo/server/Session.ts +8 -2
- package/src/tempo/server/Sse.test.ts +29 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/main.ts +9 -10
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.ts +19 -6
- package/src/tempo/session/ChannelStore.test.ts +20 -1
- package/src/tempo/session/ChannelStore.ts +77 -14
- package/src/tempo/session/Sse.ts +77 -24
- package/src/tempo/session/Ws.test.ts +410 -0
- package/src/tempo/session/Ws.ts +563 -0
- package/src/tempo/session/index.ts +1 -0
package/src/server/Mppx.test.ts
CHANGED
|
@@ -125,6 +125,111 @@ describe('request handler', () => {
|
|
|
125
125
|
expect(body.detail).not.toContain('rpc.example.com')
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
test('captures each transport request once and threads the verified envelope additively', async () => {
|
|
129
|
+
const requestMethod = Method.from({
|
|
130
|
+
name: 'mock',
|
|
131
|
+
intent: 'charge',
|
|
132
|
+
schema: {
|
|
133
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
134
|
+
request: z.object({
|
|
135
|
+
amount: z.string(),
|
|
136
|
+
currency: z.string(),
|
|
137
|
+
recipient: z.string(),
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
let captureCount = 0
|
|
143
|
+
let requestCapturedRequest: Method.CapturedRequest | undefined
|
|
144
|
+
let verifyEnvelope: Method.VerifiedChallengeEnvelope | undefined
|
|
145
|
+
let respondEnvelope: Method.VerifiedChallengeEnvelope | undefined
|
|
146
|
+
let receiptEnvelope: Method.VerifiedChallengeEnvelope | undefined
|
|
147
|
+
|
|
148
|
+
const baseTransport = Transport.http()
|
|
149
|
+
const transport = Transport.from({
|
|
150
|
+
...baseTransport,
|
|
151
|
+
captureRequest(request) {
|
|
152
|
+
captureCount++
|
|
153
|
+
return (
|
|
154
|
+
baseTransport.captureRequest?.(request) ?? {
|
|
155
|
+
headers: new Headers(request.headers),
|
|
156
|
+
method: request.method,
|
|
157
|
+
url: new URL(request.url),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
},
|
|
161
|
+
respondReceipt(options) {
|
|
162
|
+
receiptEnvelope = options.envelope
|
|
163
|
+
return baseTransport.respondReceipt(options)
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const serverMethod = Method.toServer(requestMethod, {
|
|
168
|
+
request({ capturedRequest, credential, request }) {
|
|
169
|
+
if (credential) requestCapturedRequest = capturedRequest
|
|
170
|
+
return request
|
|
171
|
+
},
|
|
172
|
+
async verify({ envelope, request }) {
|
|
173
|
+
verifyEnvelope = envelope
|
|
174
|
+
expect(envelope?.capturedRequest).toBe(requestCapturedRequest)
|
|
175
|
+
expect(request.amount).toBe('1000')
|
|
176
|
+
expect(request.currency).toBe('0x0000000000000000000000000000000000000001')
|
|
177
|
+
expect(request.recipient).toBe('0x0000000000000000000000000000000000000002')
|
|
178
|
+
expect(envelope).toBeDefined()
|
|
179
|
+
expect(Object.isFrozen(envelope!)).toBe(true)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
method: 'mock',
|
|
183
|
+
reference: 'tx-ref',
|
|
184
|
+
status: 'success',
|
|
185
|
+
timestamp: new Date().toISOString(),
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
respond({ envelope }) {
|
|
189
|
+
respondEnvelope = envelope
|
|
190
|
+
return new Response('ok')
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey, transport })
|
|
195
|
+
const options = {
|
|
196
|
+
amount: '1000',
|
|
197
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
198
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
199
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
200
|
+
} as const
|
|
201
|
+
|
|
202
|
+
const challengeResult = await handler.charge(options)(
|
|
203
|
+
new Request('https://example.com/resource?first=1'),
|
|
204
|
+
)
|
|
205
|
+
expect(challengeResult.status).toBe(402)
|
|
206
|
+
if (challengeResult.status !== 402) throw new Error()
|
|
207
|
+
|
|
208
|
+
const credential = Credential.from({
|
|
209
|
+
challenge: Challenge.fromResponse(challengeResult.challenge),
|
|
210
|
+
payload: { token: 'valid' },
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const result = await handler.charge(options)(
|
|
214
|
+
new Request('https://example.com/resource?second=1', {
|
|
215
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
expect(result.status).toBe(200)
|
|
220
|
+
if (result.status !== 200) throw new Error()
|
|
221
|
+
|
|
222
|
+
const response = result.withReceipt()
|
|
223
|
+
expect(response.status).toBe(200)
|
|
224
|
+
expect(captureCount).toBe(2)
|
|
225
|
+
expect(requestCapturedRequest?.url.pathname).toBe('/resource')
|
|
226
|
+
expect(requestCapturedRequest?.url.search).toBe('?second=1')
|
|
227
|
+
expect(verifyEnvelope?.capturedRequest).toBe(requestCapturedRequest)
|
|
228
|
+
expect(respondEnvelope?.capturedRequest).toBe(requestCapturedRequest)
|
|
229
|
+
expect(receiptEnvelope?.capturedRequest).toBe(requestCapturedRequest)
|
|
230
|
+
expect(receiptEnvelope?.challenge.id).toBe(credential.challenge.id)
|
|
231
|
+
})
|
|
232
|
+
|
|
128
233
|
test('returns 402 when challenge ID mismatch', async () => {
|
|
129
234
|
const wrongChallenge = Challenge.from({
|
|
130
235
|
id: 'wrong-id',
|
package/src/server/Mppx.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as Credential from '../Credential.js'
|
|
|
6
6
|
import * as Errors from '../Errors.js'
|
|
7
7
|
import * as Expires from '../Expires.js'
|
|
8
8
|
import * as Env from '../internal/env.js'
|
|
9
|
-
import
|
|
9
|
+
import * as Method from '../Method.js'
|
|
10
10
|
import * as PaymentRequest from '../PaymentRequest.js'
|
|
11
11
|
import type * as Receipt from '../Receipt.js'
|
|
12
12
|
import type * as z from '../zod.js'
|
|
@@ -266,6 +266,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
266
266
|
async (input: Transport.InputOf): Promise<MethodFn.Response> => {
|
|
267
267
|
const expires =
|
|
268
268
|
'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
|
|
269
|
+
const capturedRequest = await captureRequest(transport, input)
|
|
269
270
|
|
|
270
271
|
// Extract credential once — getCredential may have side effects (e.g. SSE transports).
|
|
271
272
|
const [credential, credentialError] = (() => {
|
|
@@ -282,12 +283,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
282
283
|
// Transform request if method provides a `request` function.
|
|
283
284
|
const request = (
|
|
284
285
|
parameters.request
|
|
285
|
-
? await parameters.request({ credential, request: merged } as never)
|
|
286
|
+
? await parameters.request({ capturedRequest, credential, request: merged } as never)
|
|
286
287
|
: merged
|
|
287
288
|
) as never
|
|
288
289
|
|
|
289
290
|
// Resolve realm: explicit > env var > request Host header.
|
|
290
|
-
const effectiveRealm = realm ??
|
|
291
|
+
const effectiveRealm = realm ?? resolveRealmFromCapturedRequest(capturedRequest)
|
|
291
292
|
|
|
292
293
|
// Recompute challenge from options. The HMAC-bound ID means we don't need to
|
|
293
294
|
// store challenges server-side—if the client echoes back a credential with
|
|
@@ -324,8 +325,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
324
325
|
return { challenge: response, status: 402 }
|
|
325
326
|
}
|
|
326
327
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
328
|
+
// ── Tier 1: HMAC provenance check (primary gate) ──────────────────
|
|
329
|
+
//
|
|
330
|
+
// Recompute the HMAC-SHA256 over the credential's echoed challenge
|
|
331
|
+
// parameters (realm|method|intent|request|expires|digest|opaque) and
|
|
332
|
+
// compare to the echoed `id`. This proves the challenge was issued by
|
|
333
|
+
// this server with these exact parameters — including opaque/meta,
|
|
334
|
+
// expires, and the full serialized request blob.
|
|
335
|
+
//
|
|
336
|
+
// This is the authoritative binding per §5.1.2.1.1 of the spec
|
|
337
|
+
// (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
|
|
338
|
+
// No database lookup is needed; the HMAC is stateless verification.
|
|
329
339
|
if (!Challenge.verify(credential.challenge, { secretKey })) {
|
|
330
340
|
const response = await transport.respondChallenge({
|
|
331
341
|
challenge,
|
|
@@ -339,33 +349,30 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
339
349
|
return { challenge: response, status: 402 }
|
|
340
350
|
}
|
|
341
351
|
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
352
|
+
// ── Tier 2: Pinned field safety net ──────────────────────────────
|
|
353
|
+
//
|
|
354
|
+
// The HMAC check above (Tier 1) is the primary gate — it already
|
|
355
|
+
// covers ALL challenge fields including opaque, digest, and the full
|
|
356
|
+
// serialized request. So why this second check?
|
|
357
|
+
//
|
|
358
|
+
// The `request()` hook can produce credential-dependent output: for
|
|
359
|
+
// example, `feePayer` may differ between the 402 challenge call (no
|
|
360
|
+
// credential) and the credential-bearing call. This means the
|
|
361
|
+
// recomputed challenge here has a different `request` blob — and
|
|
362
|
+
// thus a different HMAC — than the original challenge the client
|
|
363
|
+
// echoes back. The HMAC check above verifies the *echoed* challenge
|
|
364
|
+
// was signed by us, but it cannot verify that the echoed challenge
|
|
365
|
+
// matches *this route's current configuration* when the request
|
|
366
|
+
// hook transforms fields between calls.
|
|
367
|
+
//
|
|
368
|
+
// This check compares only the economically significant "pinned"
|
|
369
|
+
// fields (method, intent, realm, amount, currency, recipient, etc.)
|
|
370
|
+
// that MUST be stable across both calls. Fields like `opaque`,
|
|
371
|
+
// `digest`, and `expires` don't need explicit pinning here because
|
|
372
|
+
// they are set by server config (not derived from the request hook)
|
|
373
|
+
// and are already fully covered by the HMAC binding in Tier 1.
|
|
349
374
|
{
|
|
350
|
-
|
|
351
|
-
if (credential.challenge[field] !== challenge[field]) {
|
|
352
|
-
const response = await transport.respondChallenge({
|
|
353
|
-
challenge,
|
|
354
|
-
input,
|
|
355
|
-
error: new Errors.InvalidChallengeError({
|
|
356
|
-
id: credential.challenge.id,
|
|
357
|
-
reason: `credential ${field} does not match this route's requirements`,
|
|
358
|
-
}),
|
|
359
|
-
html: method.html,
|
|
360
|
-
})
|
|
361
|
-
return { challenge: response, status: 402 }
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const mismatch = getRequestBindingMismatch(
|
|
366
|
-
challenge.request as Record<string, unknown>,
|
|
367
|
-
credential.challenge.request as Record<string, unknown>,
|
|
368
|
-
)
|
|
375
|
+
const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge)
|
|
369
376
|
if (mismatch) {
|
|
370
377
|
const response = await transport.respondChallenge({
|
|
371
378
|
challenge,
|
|
@@ -374,6 +381,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
374
381
|
id: credential.challenge.id,
|
|
375
382
|
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
376
383
|
}),
|
|
384
|
+
html: method.html,
|
|
377
385
|
})
|
|
378
386
|
return { challenge: response, status: 402 }
|
|
379
387
|
}
|
|
@@ -402,11 +410,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
402
410
|
return { challenge: response, status: 402 }
|
|
403
411
|
}
|
|
404
412
|
|
|
413
|
+
const envelope: Method.VerifiedChallengeEnvelope = Object.freeze({
|
|
414
|
+
capturedRequest,
|
|
415
|
+
challenge: credential.challenge,
|
|
416
|
+
credential,
|
|
417
|
+
})
|
|
418
|
+
|
|
405
419
|
// User-provided verification (e.g., check signature, submit tx, verify payment).
|
|
406
420
|
// If verification fails, re-issue the challenge so the client can retry.
|
|
407
421
|
let receiptData: Receipt.Receipt
|
|
408
422
|
try {
|
|
409
|
-
receiptData = await verify({ credential, request } as never)
|
|
423
|
+
receiptData = await verify({ credential, envelope, request } as never)
|
|
410
424
|
} catch (e) {
|
|
411
425
|
if (!(e instanceof Errors.PaymentError))
|
|
412
426
|
console.error('mppx: internal verification error', e)
|
|
@@ -425,7 +439,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
425
439
|
// return the management response directly. If undefined, `withReceipt()`
|
|
426
440
|
// expects the caller to pass the user handler's response instead.
|
|
427
441
|
const managementResponse = respond
|
|
428
|
-
? await respond({ credential, input, receipt: receiptData, request } as never)
|
|
442
|
+
? await respond({ credential, envelope, input, receipt: receiptData, request } as never)
|
|
429
443
|
: undefined
|
|
430
444
|
|
|
431
445
|
return {
|
|
@@ -434,6 +448,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
434
448
|
if (managementResponse) {
|
|
435
449
|
return transport.respondReceipt({
|
|
436
450
|
credential,
|
|
451
|
+
envelope,
|
|
437
452
|
input,
|
|
438
453
|
receipt: receiptData,
|
|
439
454
|
response: managementResponse as never,
|
|
@@ -443,6 +458,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
443
458
|
if (!response) throw new Error('withReceipt() requires a response argument')
|
|
444
459
|
return transport.respondReceipt({
|
|
445
460
|
credential,
|
|
461
|
+
envelope,
|
|
446
462
|
input,
|
|
447
463
|
receipt: receiptData,
|
|
448
464
|
response: response as never,
|
|
@@ -507,14 +523,11 @@ function warnOnce(key: string, message: string) {
|
|
|
507
523
|
console.warn(`[mppx] ${message}`)
|
|
508
524
|
}
|
|
509
525
|
|
|
510
|
-
/** Extracts hostname from the request URL, falling back to a default. */
|
|
511
|
-
function
|
|
526
|
+
/** Extracts hostname from the captured request URL, falling back to a default. */
|
|
527
|
+
function resolveRealmFromCapturedRequest(capturedRequest: Method.CapturedRequest): string {
|
|
512
528
|
try {
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
const { protocol, hostname } = new URL(url)
|
|
516
|
-
if (/^https?:$/.test(protocol) && hostname) return hostname
|
|
517
|
-
}
|
|
529
|
+
const { protocol, hostname } = capturedRequest.url
|
|
530
|
+
if (/^https?:$/.test(protocol) && hostname) return hostname
|
|
518
531
|
} catch {}
|
|
519
532
|
warnOnce(
|
|
520
533
|
Warnings.realmFallback,
|
|
@@ -523,63 +536,123 @@ function resolveRealmFromRequest(input: unknown): string {
|
|
|
523
536
|
return defaultRealm
|
|
524
537
|
}
|
|
525
538
|
|
|
526
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Captures the transport request into a frozen snapshot at the start of the
|
|
541
|
+
* verification flow. This snapshot is threaded through request() → verify() →
|
|
542
|
+
* respond() → respondReceipt() so every hook sees the same authoritative
|
|
543
|
+
* request state — preventing the raw transport input from being re-read or
|
|
544
|
+
* mutated between verification steps.
|
|
545
|
+
*
|
|
546
|
+
* Note: Object.freeze is shallow — it prevents reassigning top-level properties
|
|
547
|
+
* but does not deep-freeze mutable class instances like Headers or URL. This is
|
|
548
|
+
* an accidental-mutation guard for trusted server hooks, not a security boundary.
|
|
549
|
+
*/
|
|
550
|
+
async function captureRequest(
|
|
551
|
+
transport: Transport.AnyTransport,
|
|
552
|
+
input: unknown,
|
|
553
|
+
): Promise<Method.CapturedRequest> {
|
|
554
|
+
const capturedRequest = transport.captureRequest
|
|
555
|
+
? await transport.captureRequest(input)
|
|
556
|
+
: captureRequestFromInput(input)
|
|
557
|
+
|
|
558
|
+
return Object.freeze(capturedRequest)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function captureRequestFromInput(input: unknown): Method.CapturedRequest {
|
|
562
|
+
const source = input as {
|
|
563
|
+
headers?: HeadersInit | undefined
|
|
564
|
+
method?: string | undefined
|
|
565
|
+
url?: string | URL | undefined
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
headers: new Headers(source.headers),
|
|
570
|
+
method: source.method ?? 'POST',
|
|
571
|
+
url: Transport.safeUrl(source.url),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const coreBindingFields = ['amount', 'currency', 'recipient'] as const
|
|
576
|
+
const methodBindingFields = ['chainId', 'memo', 'splits'] as const
|
|
577
|
+
const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields] as const
|
|
527
578
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
579
|
+
type CoreBindingField = (typeof coreBindingFields)[number]
|
|
580
|
+
type MethodBindingField = (typeof methodBindingFields)[number]
|
|
581
|
+
type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
|
|
582
|
+
type PinnedChallengeField = 'method' | 'intent' | 'realm' | PinnedRequestBindingField
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Compares only the fields that MUST be stable across request-hook transforms.
|
|
586
|
+
*
|
|
587
|
+
* This is NOT the primary integrity check — the HMAC binding (Challenge.verify)
|
|
588
|
+
* already covers every challenge field including opaque, digest, and the full
|
|
589
|
+
* serialized request. This function exists as a secondary safety net for the
|
|
590
|
+
* case where the `request()` hook produces credential-dependent output, causing
|
|
591
|
+
* the recomputed challenge to differ from the original in non-economic fields
|
|
592
|
+
* (e.g. `feePayer`). We only need to verify that the economically significant
|
|
593
|
+
* subset hasn't drifted.
|
|
594
|
+
*/
|
|
595
|
+
function getPinnedChallengeMismatch(
|
|
596
|
+
expectedChallenge: Challenge.Challenge,
|
|
597
|
+
actualChallenge: Challenge.Challenge,
|
|
598
|
+
): PinnedChallengeField | undefined {
|
|
599
|
+
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
600
|
+
if (actualChallenge[field] !== expectedChallenge[field]) return field
|
|
601
|
+
}
|
|
536
602
|
|
|
537
|
-
|
|
603
|
+
return getPinnedRequestBindingMismatch(
|
|
604
|
+
expectedChallenge.request as Record<string, unknown>,
|
|
605
|
+
actualChallenge.request as Record<string, unknown>,
|
|
606
|
+
)
|
|
607
|
+
}
|
|
538
608
|
|
|
539
|
-
function
|
|
609
|
+
function getPinnedRequestBindingMismatch(
|
|
540
610
|
expectedRequest: Record<string, unknown>,
|
|
541
611
|
actualRequest: Record<string, unknown>,
|
|
542
|
-
):
|
|
543
|
-
const expected =
|
|
544
|
-
const actual =
|
|
612
|
+
): PinnedRequestBindingField | undefined {
|
|
613
|
+
const expected = getPinnedRequestBinding(expectedRequest)
|
|
614
|
+
const actual = getPinnedRequestBinding(actualRequest)
|
|
545
615
|
|
|
546
|
-
return
|
|
547
|
-
(
|
|
616
|
+
return (
|
|
617
|
+
getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
|
|
618
|
+
getMethodBindingMismatch(expected.methodBinding, actual.methodBinding)
|
|
548
619
|
)
|
|
549
620
|
}
|
|
550
621
|
|
|
551
|
-
function
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
currency: request.currency ?? methodDetails.currency,
|
|
557
|
-
recipient: request.recipient ?? methodDetails.recipient,
|
|
558
|
-
chainId: request.chainId ?? methodDetails.chainId,
|
|
559
|
-
memo: methodDetails.memo,
|
|
560
|
-
splits: methodDetails.splits,
|
|
561
|
-
}
|
|
622
|
+
function getCoreBindingMismatch(
|
|
623
|
+
expected: CoreBinding,
|
|
624
|
+
actual: CoreBinding,
|
|
625
|
+
): CoreBindingField | undefined {
|
|
626
|
+
return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]))
|
|
562
627
|
}
|
|
563
628
|
|
|
564
|
-
function
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
)
|
|
569
|
-
return isDeepStrictEqual(
|
|
570
|
-
normalizeRequestBindingValue(field, expected),
|
|
571
|
-
normalizeRequestBindingValue(field, actual),
|
|
572
|
-
)
|
|
629
|
+
function getMethodBindingMismatch(
|
|
630
|
+
expected: MethodBinding,
|
|
631
|
+
actual: MethodBinding,
|
|
632
|
+
): MethodBindingField | undefined {
|
|
633
|
+
return methodBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]))
|
|
573
634
|
}
|
|
574
635
|
|
|
575
|
-
function
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
636
|
+
function getPinnedRequestBinding(request: Record<string, unknown>): PinnedRequestBinding {
|
|
637
|
+
const methodDetails = (request.methodDetails ?? {}) as Record<string, unknown>
|
|
638
|
+
const amount = normalizeScalar(request.amount ?? methodDetails.amount)
|
|
639
|
+
const chainId = normalizeScalar(request.chainId ?? methodDetails.chainId)
|
|
640
|
+
const currency = normalizeScalar(request.currency ?? methodDetails.currency)
|
|
641
|
+
const memo = normalizeHex(methodDetails.memo)
|
|
642
|
+
const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient)
|
|
643
|
+
const splits = normalizeComparable(methodDetails.splits)
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
coreBinding: {
|
|
647
|
+
...(amount !== undefined ? { amount } : {}),
|
|
648
|
+
...(currency !== undefined ? { currency } : {}),
|
|
649
|
+
...(recipient !== undefined ? { recipient } : {}),
|
|
650
|
+
},
|
|
651
|
+
methodBinding: {
|
|
652
|
+
...(chainId !== undefined ? { chainId } : {}),
|
|
653
|
+
...(memo !== undefined ? { memo } : {}),
|
|
654
|
+
...(splits !== undefined ? { splits } : {}),
|
|
655
|
+
},
|
|
583
656
|
}
|
|
584
657
|
}
|
|
585
658
|
|
|
@@ -587,11 +660,15 @@ function normalizeScalar(value: unknown): string | undefined {
|
|
|
587
660
|
return value === undefined ? undefined : String(value)
|
|
588
661
|
}
|
|
589
662
|
|
|
590
|
-
function normalizeHex(value: unknown):
|
|
591
|
-
|
|
663
|
+
function normalizeHex(value: unknown): string | undefined {
|
|
664
|
+
if (value === undefined) return undefined
|
|
665
|
+
|
|
666
|
+
const normalized = String(value)
|
|
667
|
+
return normalized.startsWith('0x') ? normalized.toLowerCase() : normalized
|
|
592
668
|
}
|
|
593
669
|
|
|
594
670
|
function normalizeComparable(value: unknown): unknown {
|
|
671
|
+
if (value === undefined) return undefined
|
|
595
672
|
if (Array.isArray(value)) return value.map(normalizeComparable)
|
|
596
673
|
|
|
597
674
|
if (value && typeof value === 'object') {
|
|
@@ -602,7 +679,20 @@ function normalizeComparable(value: unknown): unknown {
|
|
|
602
679
|
)
|
|
603
680
|
}
|
|
604
681
|
|
|
605
|
-
return normalizeHex(value)
|
|
682
|
+
return typeof value === 'string' ? normalizeHex(value) : value
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
type CoreBinding = {
|
|
686
|
+
[field in CoreBindingField]?: string
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
type MethodBinding = {
|
|
690
|
+
[field in MethodBindingField]?: unknown
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
type PinnedRequestBinding = {
|
|
694
|
+
coreBinding: CoreBinding
|
|
695
|
+
methodBinding: MethodBinding
|
|
606
696
|
}
|
|
607
697
|
|
|
608
698
|
export type MethodFn<
|
|
@@ -773,7 +863,7 @@ export function compose(
|
|
|
773
863
|
if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
|
|
774
864
|
const canonical = meta._canonicalRequest
|
|
775
865
|
if (!canonical) return true
|
|
776
|
-
return !
|
|
866
|
+
return !getPinnedRequestBindingMismatch(canonical, credReq)
|
|
777
867
|
})
|
|
778
868
|
|
|
779
869
|
const match =
|
|
@@ -33,6 +33,25 @@ const receipt = Receipt.from({
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
describe('http', () => {
|
|
36
|
+
describe('captureRequest', () => {
|
|
37
|
+
test('captures method, url, and headers into a cloned snapshot', async () => {
|
|
38
|
+
const transport = Transport.http()
|
|
39
|
+
const request = new Request('https://example.com/resource?foo=bar', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { Authorization: Credential.serialize(credential), 'X-Test': '1' },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const captured = await transport.captureRequest?.(request)
|
|
45
|
+
expect(captured).toEqual({
|
|
46
|
+
headers: new Headers(request.headers),
|
|
47
|
+
method: 'POST',
|
|
48
|
+
url: new URL('https://example.com/resource?foo=bar'),
|
|
49
|
+
})
|
|
50
|
+
expect(captured).not.toBe(request)
|
|
51
|
+
expect(captured?.headers).not.toBe(request.headers)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
36
55
|
describe('getCredential', () => {
|
|
37
56
|
test('returns credential from Authorization header', () => {
|
|
38
57
|
const transport = Transport.http()
|
|
@@ -387,6 +406,18 @@ describe('mcp', () => {
|
|
|
387
406
|
},
|
|
388
407
|
}
|
|
389
408
|
|
|
409
|
+
describe('captureRequest', () => {
|
|
410
|
+
test('captures MCP method into a synthetic request snapshot', async () => {
|
|
411
|
+
const transport = Transport.mcp()
|
|
412
|
+
|
|
413
|
+
expect(await transport.captureRequest?.(mcpRequest)).toEqual({
|
|
414
|
+
headers: new Headers(),
|
|
415
|
+
method: 'POST',
|
|
416
|
+
url: new URL('mcp://request/tools%2Fcall'),
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
390
421
|
describe('getCredential', () => {
|
|
391
422
|
test('returns credential from _meta', () => {
|
|
392
423
|
const transport = Transport.mcp()
|
package/src/server/Transport.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as Challenge from '../Challenge.js'
|
|
2
2
|
import * as Credential from '../Credential.js'
|
|
3
3
|
import * as Errors from '../Errors.js'
|
|
4
|
-
import type { Distribute, UnionToIntersection } from '../internal/types.js'
|
|
4
|
+
import type { Distribute, MaybePromise, UnionToIntersection } from '../internal/types.js'
|
|
5
5
|
import * as core_Mcp from '../Mcp.js'
|
|
6
|
+
import type * as Method from '../Method.js'
|
|
6
7
|
import * as Receipt from '../Receipt.js'
|
|
7
8
|
import * as Html from './internal/html/config.js'
|
|
8
9
|
import { serviceWorker } from './internal/html/serviceWorker.gen.js'
|
|
@@ -23,6 +24,8 @@ export type Transport<
|
|
|
23
24
|
> = {
|
|
24
25
|
/** Transport name for identification. */
|
|
25
26
|
name: string
|
|
27
|
+
/** Captures the transport request into an immutable verification snapshot. */
|
|
28
|
+
captureRequest?: ((input: input) => MaybePromise<Method.CapturedRequest>) | undefined
|
|
26
29
|
/**
|
|
27
30
|
* Extracts credential from the transport input.
|
|
28
31
|
* Returns `null` if no credential was provided, or throws if malformed.
|
|
@@ -39,6 +42,7 @@ export type Transport<
|
|
|
39
42
|
respondReceipt: (options: {
|
|
40
43
|
challengeId: string
|
|
41
44
|
credential: Credential.Credential
|
|
45
|
+
envelope?: Method.VerifiedChallengeEnvelope | undefined
|
|
42
46
|
input: input
|
|
43
47
|
receipt: Receipt.Receipt
|
|
44
48
|
response: receiptResponse
|
|
@@ -90,9 +94,10 @@ export type WithReceipt<transport extends AnyTransport = Http> = WithReceiptOver
|
|
|
90
94
|
*
|
|
91
95
|
* const custom = Transport.from({
|
|
92
96
|
* name: 'custom',
|
|
97
|
+
* captureRequest(input) { ... },
|
|
93
98
|
* getCredential(input) { ... },
|
|
94
99
|
* respondChallenge({ challenge, input }) { ... },
|
|
95
|
-
* respondReceipt({ receipt, response, challengeId, credential, input }) { ... },
|
|
100
|
+
* respondReceipt({ receipt, response, challengeId, credential, envelope, input }) { ... },
|
|
96
101
|
* })
|
|
97
102
|
* ```
|
|
98
103
|
*/
|
|
@@ -118,6 +123,14 @@ export function http(): Http {
|
|
|
118
123
|
return from<Request, Response>({
|
|
119
124
|
name: 'http',
|
|
120
125
|
|
|
126
|
+
captureRequest(request) {
|
|
127
|
+
return {
|
|
128
|
+
headers: new Headers(request.headers),
|
|
129
|
+
method: request.method,
|
|
130
|
+
url: safeUrl(request.url),
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
121
134
|
getCredential(request) {
|
|
122
135
|
const header = request.headers.get('Authorization')
|
|
123
136
|
if (!header) return null
|
|
@@ -206,6 +219,14 @@ export function mcp() {
|
|
|
206
219
|
return from<core_Mcp.JsonRpcRequest, core_Mcp.Response>({
|
|
207
220
|
name: 'mcp',
|
|
208
221
|
|
|
222
|
+
captureRequest(request) {
|
|
223
|
+
return {
|
|
224
|
+
headers: new Headers(),
|
|
225
|
+
method: 'POST',
|
|
226
|
+
url: new URL(`mcp://request/${encodeURIComponent(request.method ?? 'unknown')}`),
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
209
230
|
getCredential(request) {
|
|
210
231
|
const meta = request.params?._meta
|
|
211
232
|
const credential = meta?.[core_Mcp.credentialMetaKey]
|
|
@@ -259,6 +280,14 @@ function mcpErrorCode(error?: Errors.PaymentError): number {
|
|
|
259
280
|
return core_Mcp.paymentVerificationFailedCode
|
|
260
281
|
}
|
|
261
282
|
|
|
283
|
+
export function safeUrl(url: string | URL | undefined): URL {
|
|
284
|
+
try {
|
|
285
|
+
if (url instanceof URL) return new URL(url.toString())
|
|
286
|
+
if (url) return new URL(url)
|
|
287
|
+
} catch {}
|
|
288
|
+
return new URL('about:blank')
|
|
289
|
+
}
|
|
290
|
+
|
|
262
291
|
/** @internal Distributes over the receipt response union to create overloads. */
|
|
263
292
|
type WithReceiptOverloads<transport extends AnyTransport = Http> = {
|
|
264
293
|
// biome-ignore lint/style/useShorthandFunctionType: _
|