mppx 0.5.7 → 0.5.9

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -51,6 +51,14 @@ export function mcpSdk(): McpSdk {
51
51
  return Transport.from<Extra, McpError, CallToolResult>({
52
52
  name: 'mcp-sdk',
53
53
 
54
+ captureRequest() {
55
+ return {
56
+ headers: new Headers(),
57
+ method: 'POST',
58
+ url: new URL('mcp://request/sdk'),
59
+ }
60
+ },
61
+
54
62
  getCredential(extra) {
55
63
  const credential = extra._meta?.[core_Mcp.credentialMetaKey]
56
64
  if (!credential) return null
@@ -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',
@@ -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 type * as Method from '../Method.js'
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 ?? resolveRealmFromRequest(input)
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
- // Verify the echoed challenge was issued by us by recomputing its HMAC.
328
- // This is stateless—no database lookup needed.
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
- // Verify the credential's challenge matches this route's configured
343
- // method, intent, realm, and request. Prevents cross-route scope
344
- // confusion where a credential issued for a cheap route (or different
345
- // method/intent) is presented at an expensive route.
346
- // Note: we compare specific payment parameters rather than the full
347
- // request because the `request` hook may produce credential-dependent
348
- // output (e.g. `feePayer` differs between 402 and credential calls).
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
- for (const field of ['method', 'intent', 'realm'] as const) {
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 resolveRealmFromRequest(input: unknown): string {
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 url = typeof (input as any)?.url === 'string' ? (input as any).url : undefined
514
- if (url) {
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
- type RequestBindingField = 'amount' | 'currency' | 'recipient' | 'chainId' | 'memo' | 'splits'
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
- const requestBindingFields = [
529
- 'amount',
530
- 'currency',
531
- 'recipient',
532
- 'chainId',
533
- 'memo',
534
- 'splits',
535
- ] as const satisfies readonly RequestBindingField[]
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
- type RequestBinding = Partial<Record<RequestBindingField, unknown>>
603
+ return getPinnedRequestBindingMismatch(
604
+ expectedChallenge.request as Record<string, unknown>,
605
+ actualChallenge.request as Record<string, unknown>,
606
+ )
607
+ }
538
608
 
539
- function getRequestBindingMismatch(
609
+ function getPinnedRequestBindingMismatch(
540
610
  expectedRequest: Record<string, unknown>,
541
611
  actualRequest: Record<string, unknown>,
542
- ): RequestBindingField | undefined {
543
- const expected = getRequestBinding(expectedRequest)
544
- const actual = getRequestBinding(actualRequest)
612
+ ): PinnedRequestBindingField | undefined {
613
+ const expected = getPinnedRequestBinding(expectedRequest)
614
+ const actual = getPinnedRequestBinding(actualRequest)
545
615
 
546
- return requestBindingFields.find(
547
- (field) => !requestBindingValuesMatch(field, expected[field], actual[field]),
616
+ return (
617
+ getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
618
+ getMethodBindingMismatch(expected.methodBinding, actual.methodBinding)
548
619
  )
549
620
  }
550
621
 
551
- function getRequestBinding(request: Record<string, unknown>): RequestBinding {
552
- const methodDetails = (request.methodDetails ?? {}) as Record<string, unknown>
553
-
554
- return {
555
- amount: request.amount ?? methodDetails.amount,
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 requestBindingValuesMatch(
565
- field: RequestBindingField,
566
- expected: unknown,
567
- actual: unknown,
568
- ): boolean {
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 normalizeRequestBindingValue(field: RequestBindingField, value: unknown): unknown {
576
- switch (field) {
577
- case 'memo':
578
- return normalizeHex(value)
579
- case 'splits':
580
- return normalizeComparable(value)
581
- default:
582
- return normalizeScalar(value)
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): unknown {
591
- return typeof value === 'string' && value.startsWith('0x') ? value.toLowerCase() : value
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 !getRequestBindingMismatch(canonical, credReq)
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()
@@ -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: _