mppx 0.3.12 → 0.3.14

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 (66) hide show
  1. package/dist/Challenge.d.ts +1 -1
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +107 -15
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/client/internal/Fetch.d.ts.map +1 -1
  6. package/dist/client/internal/Fetch.js +55 -9
  7. package/dist/client/internal/Fetch.js.map +1 -1
  8. package/dist/internal/constantTimeEqual.d.ts.map +1 -1
  9. package/dist/internal/constantTimeEqual.js +7 -4
  10. package/dist/internal/constantTimeEqual.js.map +1 -1
  11. package/dist/server/Mppx.d.ts.map +1 -1
  12. package/dist/server/Mppx.js +2 -1
  13. package/dist/server/Mppx.js.map +1 -1
  14. package/dist/stripe/Methods.d.ts +0 -3
  15. package/dist/stripe/Methods.d.ts.map +1 -1
  16. package/dist/stripe/Methods.js +0 -2
  17. package/dist/stripe/Methods.js.map +1 -1
  18. package/dist/stripe/client/Charge.d.ts +0 -3
  19. package/dist/stripe/client/Charge.d.ts.map +1 -1
  20. package/dist/stripe/client/Charge.js +2 -2
  21. package/dist/stripe/client/Charge.js.map +1 -1
  22. package/dist/stripe/client/Methods.d.ts +0 -3
  23. package/dist/stripe/client/Methods.d.ts.map +1 -1
  24. package/dist/stripe/server/Charge.d.ts +0 -3
  25. package/dist/stripe/server/Charge.d.ts.map +1 -1
  26. package/dist/stripe/server/Charge.js +2 -2
  27. package/dist/stripe/server/Charge.js.map +1 -1
  28. package/dist/stripe/server/Methods.d.ts +0 -3
  29. package/dist/stripe/server/Methods.d.ts.map +1 -1
  30. package/dist/tempo/Methods.d.ts +0 -3
  31. package/dist/tempo/Methods.d.ts.map +1 -1
  32. package/dist/tempo/Methods.js +3 -3
  33. package/dist/tempo/Methods.js.map +1 -1
  34. package/dist/tempo/client/Charge.d.ts +13 -3
  35. package/dist/tempo/client/Charge.d.ts.map +1 -1
  36. package/dist/tempo/client/Charge.js +18 -1
  37. package/dist/tempo/client/Charge.js.map +1 -1
  38. package/dist/tempo/client/Methods.d.ts +4 -3
  39. package/dist/tempo/client/Methods.d.ts.map +1 -1
  40. package/dist/tempo/server/Charge.d.ts +0 -3
  41. package/dist/tempo/server/Charge.d.ts.map +1 -1
  42. package/dist/tempo/server/Charge.js +2 -1
  43. package/dist/tempo/server/Charge.js.map +1 -1
  44. package/dist/tempo/server/Methods.d.ts +0 -3
  45. package/dist/tempo/server/Methods.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/Challenge.test.ts +94 -18
  48. package/src/Challenge.ts +118 -15
  49. package/src/PaymentRequest.test.ts +0 -5
  50. package/src/client/Mppx.test.ts +5 -5
  51. package/src/client/Transport.test.ts +5 -8
  52. package/src/client/internal/Fetch.browser.test.ts +135 -0
  53. package/src/client/internal/Fetch.test.ts +16 -60
  54. package/src/client/internal/Fetch.ts +66 -9
  55. package/src/internal/constantTimeEqual.ts +6 -4
  56. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  57. package/src/server/Mppx.ts +3 -1
  58. package/src/server/Transport.test.ts +6 -9
  59. package/src/stripe/Methods.ts +0 -2
  60. package/src/stripe/client/Charge.ts +2 -2
  61. package/src/stripe/server/Charge.ts +2 -2
  62. package/src/tempo/Methods.test.ts +22 -0
  63. package/src/tempo/Methods.ts +3 -3
  64. package/src/tempo/client/Charge.ts +29 -1
  65. package/src/tempo/server/Charge.test.ts +34 -72
  66. package/src/tempo/server/Charge.ts +2 -1
@@ -250,12 +250,12 @@ describe('fromMethod', () => {
250
250
  const challenge = Challenge.fromMethod(Methods.charge, {
251
251
  id: 'abc123',
252
252
  realm: 'api.example.com',
253
+ expires: '2025-01-06T12:00:00Z',
253
254
  request: {
254
255
  amount: '1',
255
256
  currency: '0x20c0000000000000000000000000000000000001',
256
257
  decimals: 6,
257
258
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
258
- expires: '2025-01-06T12:00:00Z',
259
259
  },
260
260
  })
261
261
 
@@ -269,7 +269,6 @@ describe('fromMethod', () => {
269
269
  "request": {
270
270
  "amount": "1000000",
271
271
  "currency": "0x20c0000000000000000000000000000000000001",
272
- "expires": "2025-01-06T12:00:00Z",
273
272
  "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
274
273
  },
275
274
  }
@@ -280,12 +279,12 @@ describe('fromMethod', () => {
280
279
  const challenge = Challenge.fromMethod(Methods.charge, {
281
280
  id: 'abc123',
282
281
  realm: 'api.example.com',
282
+ expires: '2025-01-06T12:00:00Z',
283
283
  request: {
284
284
  amount: '1',
285
285
  currency: '0x20c0000000000000000000000000000000000001',
286
286
  decimals: 6,
287
287
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
288
- expires: '2025-01-06T12:00:00Z',
289
288
  chainId: 42431,
290
289
  feePayer: true,
291
290
  },
@@ -301,7 +300,6 @@ describe('fromMethod', () => {
301
300
  "request": {
302
301
  "amount": "1000000",
303
302
  "currency": "0x20c0000000000000000000000000000000000001",
304
- "expires": "2025-01-06T12:00:00Z",
305
303
  "methodDetails": {
306
304
  "chainId": 42431,
307
305
  "feePayer": true,
@@ -321,7 +319,6 @@ describe('fromMethod', () => {
321
319
  currency: '0x20c0000000000000000000000000000000000001',
322
320
  decimals: 6,
323
321
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
324
- expires: '2025-01-06T12:00:00Z',
325
322
  },
326
323
  digest: 'sha-256=abc',
327
324
  expires: '2025-01-06T12:00:00Z',
@@ -334,12 +331,12 @@ describe('fromMethod', () => {
334
331
  test('behavior: creates challenge with HMAC-bound id via secretKey', () => {
335
332
  const challenge = Challenge.fromMethod(Methods.charge, {
336
333
  realm: 'api.example.com',
334
+ expires: '2025-01-06T12:00:00Z',
337
335
  request: {
338
336
  amount: '1',
339
337
  currency: '0x20c0000000000000000000000000000000000001',
340
338
  decimals: 6,
341
339
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
342
- expires: '2025-01-06T12:00:00Z',
343
340
  },
344
341
  secretKey: 'my-secret',
345
342
  })
@@ -358,7 +355,6 @@ describe('fromMethod', () => {
358
355
  amount: 123,
359
356
  currency: '0x20c0000000000000000000000000000000000001',
360
357
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
361
- expires: '2025-01-06T12:00:00Z',
362
358
  } as any,
363
359
  }),
364
360
  ).toThrow()
@@ -402,17 +398,18 @@ describe('serialize', () => {
402
398
  })
403
399
 
404
400
  describe('deserialize', () => {
405
- test('behavior: deserializes WWW-Authenticate header', () => {
406
- const original = Challenge.from({
401
+ const paymentHeader = Challenge.serialize(
402
+ Challenge.from({
407
403
  id: 'abc123',
408
404
  realm: 'api.example.com',
409
405
  method: 'tempo',
410
406
  intent: 'charge',
411
407
  request: { amount: '1000000', currency: 'USD' },
412
- })
408
+ }),
409
+ )
413
410
 
414
- const header = Challenge.serialize(original)
415
- const challenge = Challenge.deserialize(header)
411
+ test('behavior: deserializes WWW-Authenticate header', () => {
412
+ const challenge = Challenge.deserialize(paymentHeader)
416
413
 
417
414
  expect(challenge).toMatchInlineSnapshot(`
418
415
  {
@@ -446,20 +443,100 @@ describe('deserialize', () => {
446
443
  expect(challenge?.expires).toBe('2025-01-06T12:00:00Z')
447
444
  })
448
445
 
449
- test('error: throws for missing Payment scheme', () => {
450
- expect(() => Challenge.deserialize('Bearer token')).toThrow('Missing Payment scheme.')
446
+ test.each([
447
+ { name: 'single Payment scheme', header: paymentHeader },
448
+ { name: 'Payment after another scheme', header: `Bearer realm="api", ${paymentHeader}` },
449
+ {
450
+ name: 'scheme token is case-insensitive',
451
+ header: paymentHeader.replace(/^Payment /, 'payment '),
452
+ },
453
+ {
454
+ name: 'quoted values in previous schemes do not interfere',
455
+ header: `Bearer error_description="retry with Payment challenge", ${paymentHeader}`,
456
+ },
457
+ {
458
+ name: 'parses Payment params before the next scheme token',
459
+ header: `${paymentHeader}, Bearer realm="fallback"`,
460
+ },
461
+ ])('behavior: extracts challenge for $name', ({ header }) => {
462
+ const challenge = Challenge.deserialize(header)
463
+
464
+ expect(challenge.id).toBe('abc123')
465
+ expect(challenge.method).toBe('tempo')
466
+ expect(challenge.intent).toBe('charge')
467
+ })
468
+
469
+ const request = /request="([^"]+)"/.exec(paymentHeader)?.[1]
470
+ if (!request) throw new Error('request missing from serialized challenge')
471
+
472
+ test.each([
473
+ {
474
+ name: 'escaped quotes',
475
+ headerValue: 'premium \\"access\\"',
476
+ expected: 'premium "access"',
477
+ },
478
+ {
479
+ name: 'escaped comma',
480
+ headerValue: 'tier\\,premium',
481
+ expected: 'tier,premium',
482
+ },
483
+ {
484
+ name: 'escaped backslash',
485
+ headerValue: 'path\\\\alpha',
486
+ expected: 'path\\alpha',
487
+ },
488
+ ])('behavior: deserializes $name in quoted-string values', ({ headerValue, expected }) => {
489
+ const header =
490
+ 'Payment id="abc123", realm="api.example.com", method="tempo", intent="charge", request="' +
491
+ request +
492
+ `", description="${headerValue}"`
493
+
494
+ const challenge = Challenge.deserialize(header)
495
+ expect(challenge.description).toBe(expected)
496
+ })
497
+
498
+ test.each([
499
+ {
500
+ name: 'missing Payment scheme',
501
+ header: 'Bearer token',
502
+ error: 'Missing Payment scheme.',
503
+ },
504
+ {
505
+ name: 'duplicate parameters',
506
+ header: 'Payment id="a", realm="api", method="tempo", intent="charge", request="e30", id="b"',
507
+ error: 'Duplicate parameter: id',
508
+ },
509
+ {
510
+ name: 'unterminated quoted-string',
511
+ header:
512
+ 'Payment id="a", realm="api", method="tempo", intent="charge", request="e30", description="oops',
513
+ error: 'Unterminated quoted-string.',
514
+ },
515
+ {
516
+ name: 'invalid method casing',
517
+ header: 'Payment id="a", realm="api", method="Tempo", intent="charge", request="e30"',
518
+ error: 'Invalid method: "Tempo". Must be lowercase per spec.',
519
+ },
520
+ {
521
+ name: 'malformed auth-param',
522
+ header:
523
+ 'Payment id="a", realm="api", method="tempo", intent="charge", request="e30", ="value"',
524
+ error: 'Malformed auth-param.',
525
+ },
526
+ ])('error: throws for $name', ({ header, error }) => {
527
+ expect(() => Challenge.deserialize(header)).toThrow(error)
451
528
  })
452
529
 
453
530
  test('error: missing required fields', () => {
454
531
  expect(() => Challenge.deserialize('Payment realm="test"')).toThrow()
455
532
  })
456
533
 
457
- test('error: throws for duplicate parameters', () => {
534
+ test('error: missing request parameter after valid Payment scheme', () => {
458
535
  expect(() =>
459
536
  Challenge.deserialize(
460
- 'Payment id="a", realm="api", method="tempo", intent="charge", request="e30", id="b"',
537
+ 'Bearer realm="api", Payment id="a", realm="api", method="tempo", intent="charge"',
461
538
  ),
462
- ).toThrow('Duplicate parameter: id')
539
+ ).toThrow('Missing request parameter.')
463
540
  })
464
541
  })
465
542
 
@@ -584,7 +661,6 @@ describe('opaque', () => {
584
661
  currency: '0x20c0000000000000000000000000000000000001',
585
662
  decimals: 6,
586
663
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
587
- expires: '2025-01-06T12:00:00Z',
588
664
  },
589
665
  meta: { payment_intent: 'pi_3abc123XYZ' },
590
666
  })
package/src/Challenge.ts CHANGED
@@ -125,7 +125,7 @@ export function from<
125
125
  secretKey,
126
126
  } = parameters
127
127
 
128
- const expires = (parameters.expires ?? request.expires) as string
128
+ const expires = parameters.expires as string
129
129
  const id = secretKey
130
130
  ? computeId({ ...parameters, expires, ...(meta && { opaque: meta }) }, { secretKey })
131
131
  : (parameters as { id: string }).id
@@ -205,11 +205,11 @@ export declare namespace from {
205
205
  * Methods.charge,
206
206
  * {
207
207
  * realm: 'api.example.com',
208
+ * expires: '2025-01-06T12:00:00Z',
208
209
  * request: {
209
210
  * amount: '1000000',
210
211
  * currency: '0x20c0000000000000000000000000000000000001',
211
212
  * recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
212
- * expires: '2025-01-06T12:00:00Z',
213
213
  * },
214
214
  * },
215
215
  * { secretKey: 'my-secret' },
@@ -319,20 +319,10 @@ export function deserialize<const methods extends readonly Method.Method[] | und
319
319
  value: string,
320
320
  options?: from.Options<methods>,
321
321
  ): from.ReturnType<from.Parameters, methods> {
322
- const prefixMatch = value.match(/^Payment\s+(.+)$/i)
323
- if (!prefixMatch?.[1]) throw new Error('Missing Payment scheme.')
322
+ const params = extractPaymentAuthParams(value)
323
+ if (!params) throw new Error('Missing Payment scheme.')
324
324
 
325
- const params = prefixMatch[1]
326
- const result: Record<string, string> = {}
327
-
328
- for (const match of params.matchAll(/(\w+)="([^"]+)"/g)) {
329
- const key = match[1]
330
- const value = match[2]
331
- if (key && value) {
332
- if (key in result) throw new Error(`Duplicate parameter: ${key}`)
333
- result[key] = value
334
- }
335
- }
325
+ const result = parseAuthParams(params)
336
326
 
337
327
  const { request, opaque, ...rest } = result
338
328
  if (!request) throw new Error('Missing request parameter.')
@@ -349,6 +339,119 @@ export function deserialize<const methods extends readonly Method.Method[] | und
349
339
  )
350
340
  }
351
341
 
342
+ /** @internal Extracts the `Payment` scheme from a WWW-Authenticate value that may contain multiple schemes. */
343
+ function extractPaymentAuthParams(header: string): string | null {
344
+ const token = 'Payment'
345
+ let inQuotes = false
346
+ let escaped = false
347
+
348
+ for (let i = 0; i < header.length; i++) {
349
+ const char = header[i]
350
+
351
+ if (inQuotes) {
352
+ if (escaped) escaped = false
353
+ else if (char === '\\') escaped = true
354
+ else if (char === '"') inQuotes = false
355
+ continue
356
+ }
357
+
358
+ if (char === '"') {
359
+ inQuotes = true
360
+ continue
361
+ }
362
+
363
+ if (!startsWithSchemeToken(header, i, token)) continue
364
+
365
+ const prefix = header.slice(0, i)
366
+ if (prefix.trim() && !prefix.trimEnd().endsWith(',')) continue
367
+
368
+ let paramsStart = i + token.length
369
+ while (paramsStart < header.length && /\s/.test(header[paramsStart] ?? '')) paramsStart++
370
+ return header.slice(paramsStart)
371
+ }
372
+
373
+ return null
374
+ }
375
+
376
+ /** @internal Parses auth-params with support for escaped quoted-string values. */
377
+ function parseAuthParams(input: string): Record<string, string> {
378
+ const result: Record<string, string> = {}
379
+ let i = 0
380
+
381
+ while (i < input.length) {
382
+ while (i < input.length && /[\s,]/.test(input[i] ?? '')) i++
383
+ if (i >= input.length) break
384
+
385
+ const keyStart = i
386
+ while (i < input.length && /[A-Za-z0-9_-]/.test(input[i] ?? '')) i++
387
+ const key = input.slice(keyStart, i)
388
+ if (!key) throw new Error('Malformed auth-param.')
389
+
390
+ while (i < input.length && /\s/.test(input[i] ?? '')) i++
391
+
392
+ // If there is no '=' after a token, this is likely another auth scheme.
393
+ if (input[i] !== '=') break
394
+ i++
395
+
396
+ while (i < input.length && /\s/.test(input[i] ?? '')) i++
397
+
398
+ const [value, nextIndex] = readAuthParamValue(input, i)
399
+ i = nextIndex
400
+
401
+ if (key in result) throw new Error(`Duplicate parameter: ${key}`)
402
+ result[key] = value
403
+ }
404
+
405
+ return result
406
+ }
407
+
408
+ /** @internal */
409
+ function readAuthParamValue(input: string, start: number): [value: string, nextIndex: number] {
410
+ if (input[start] === '"') return readQuotedAuthParamValue(input, start + 1)
411
+
412
+ let i = start
413
+ while (i < input.length && input[i] !== ',') i++
414
+ return [input.slice(start, i).trim(), i]
415
+ }
416
+
417
+ /** @internal */
418
+ function readQuotedAuthParamValue(
419
+ input: string,
420
+ start: number,
421
+ ): [value: string, nextIndex: number] {
422
+ let i = start
423
+ let value = ''
424
+ let escaped = false
425
+
426
+ while (i < input.length) {
427
+ const char = input[i]!
428
+ i++
429
+
430
+ if (escaped) {
431
+ value += char
432
+ escaped = false
433
+ continue
434
+ }
435
+
436
+ if (char === '\\') {
437
+ escaped = true
438
+ continue
439
+ }
440
+
441
+ if (char === '"') return [value, i]
442
+ value += char
443
+ }
444
+
445
+ throw new Error('Unterminated quoted-string.')
446
+ }
447
+
448
+ /** @internal */
449
+ function startsWithSchemeToken(value: string, index: number, token: string): boolean {
450
+ if (!value.slice(index).toLowerCase().startsWith(token.toLowerCase())) return false
451
+ const next = value[index + token.length]
452
+ return Boolean(next && /\s/.test(next))
453
+ }
454
+
352
455
  /**
353
456
  * Extracts the challenge from a Headers object.
354
457
  *
@@ -26,13 +26,11 @@ describe('fromMethod', () => {
26
26
  currency: '0x20c0000000000000000000000000000000000001',
27
27
  decimals: 6,
28
28
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
29
- expires: '2025-01-06T12:00:00Z',
30
29
  })
31
30
  expect(request).toMatchInlineSnapshot(`
32
31
  {
33
32
  "amount": "1000000",
34
33
  "currency": "0x20c0000000000000000000000000000000000001",
35
- "expires": "2025-01-06T12:00:00Z",
36
34
  "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
37
35
  }
38
36
  `)
@@ -44,14 +42,12 @@ describe('fromMethod', () => {
44
42
  currency: '0x20c0000000000000000000000000000000000001',
45
43
  decimals: 6,
46
44
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
47
- expires: '2025-01-06T12:00:00Z',
48
45
  chainId: 42431,
49
46
  })
50
47
  expect(request).toMatchInlineSnapshot(`
51
48
  {
52
49
  "amount": "1000000",
53
50
  "currency": "0x20c0000000000000000000000000000000000001",
54
- "expires": "2025-01-06T12:00:00Z",
55
51
  "methodDetails": {
56
52
  "chainId": 42431,
57
53
  },
@@ -66,7 +62,6 @@ describe('fromMethod', () => {
66
62
  amount: 123,
67
63
  currency: '0x20c0000000000000000000000000000000000001',
68
64
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
69
- expires: '2025-01-06T12:00:00Z',
70
65
  } as any),
71
66
  ).toThrowErrorMatchingInlineSnapshot(`
72
67
  [$ZodError: [
@@ -82,12 +82,12 @@ describe('createCredential', () => {
82
82
  const challenge = Challenge.fromMethod(Methods.charge, {
83
83
  realm,
84
84
  secretKey,
85
+ expires: new Date(Date.now() + 60_000).toISOString(),
85
86
  request: {
86
87
  amount: '1000',
87
88
  currency: '0x1234567890123456789012345678901234567890',
88
89
  decimals: 6,
89
90
  recipient: '0x1234567890123456789012345678901234567890',
90
- expires: new Date(Date.now() + 60_000).toISOString(),
91
91
  },
92
92
  })
93
93
 
@@ -164,11 +164,11 @@ describe('createCredential', () => {
164
164
  realm,
165
165
  method: 'stripe',
166
166
  intent: 'charge',
167
+ expires: new Date(Date.now() + 60_000).toISOString(),
167
168
  request: {
168
169
  amount: '2000',
169
170
  currency: '0xabcd',
170
171
  recipient: '0xefgh',
171
- expires: new Date(Date.now() + 60_000).toISOString(),
172
172
  },
173
173
  })
174
174
 
@@ -195,12 +195,12 @@ describe('createCredential', () => {
195
195
  const challenge = Challenge.fromMethod(Methods.charge, {
196
196
  realm,
197
197
  secretKey,
198
+ expires: new Date(Date.now() + 60_000).toISOString(),
198
199
  request: {
199
200
  amount: '1000',
200
201
  currency: '0x1234567890123456789012345678901234567890',
201
202
  decimals: 6,
202
203
  recipient: '0x1234567890123456789012345678901234567890',
203
- expires: new Date(Date.now() + 60_000).toISOString(),
204
204
  },
205
205
  })
206
206
 
@@ -227,12 +227,12 @@ describe('createCredential', () => {
227
227
  const challenge = Challenge.fromMethod(Methods.charge, {
228
228
  realm,
229
229
  secretKey,
230
+ expires: new Date(Date.now() + 60_000).toISOString(),
230
231
  request: {
231
232
  amount: '1000',
232
233
  currency: '0x1234567890123456789012345678901234567890',
233
234
  decimals: 6,
234
235
  recipient: '0x1234567890123456789012345678901234567890',
235
- expires: new Date(Date.now() + 60_000).toISOString(),
236
236
  },
237
237
  })
238
238
 
@@ -258,12 +258,12 @@ describe('createCredential', () => {
258
258
  const challenge = Challenge.fromMethod(Methods.charge, {
259
259
  realm,
260
260
  secretKey,
261
+ expires: new Date(Date.now() + 60_000).toISOString(),
261
262
  request: {
262
263
  amount: '1000',
263
264
  currency: '0x1234567890123456789012345678901234567890',
264
265
  decimals: 6,
265
266
  recipient: '0x1234567890123456789012345678901234567890',
266
- expires: new Date(Date.now() + 60_000).toISOString(),
267
267
  },
268
268
  })
269
269
 
@@ -9,12 +9,12 @@ const secretKey = 'test-secret-key'
9
9
  const challenge = Challenge.fromMethod(Methods.charge, {
10
10
  realm,
11
11
  secretKey,
12
+ expires: '2025-01-01T00:00:00.000Z',
12
13
  request: {
13
14
  amount: '0.001',
14
15
  currency: '0x20c0000000000000000000000000000000000001',
15
16
  decimals: 6,
16
17
  recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
17
- expires: '2025-01-01T00:00:00.000Z',
18
18
  },
19
19
  })
20
20
 
@@ -60,14 +60,13 @@ describe('http', () => {
60
60
  expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
61
61
  {
62
62
  "expires": "2025-01-01T00:00:00.000Z",
63
- "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
63
+ "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
64
64
  "intent": "charge",
65
65
  "method": "tempo",
66
66
  "realm": "api.example.com",
67
67
  "request": {
68
68
  "amount": "1000",
69
69
  "currency": "0x20c0000000000000000000000000000000000001",
70
- "expires": "2025-01-01T00:00:00.000Z",
71
70
  "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
72
71
  },
73
72
  }
@@ -91,7 +90,7 @@ describe('http', () => {
91
90
  const headers = result.headers as Headers
92
91
 
93
92
  expect(headers.get('Authorization')).toMatchInlineSnapshot(
94
- `"Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiIsImlkIjoiejhkVWk2MWxWaU9qNmN3aF9JU2JfNVg4bkJKRjJPalR5ZGNFYXA4d1gwbyIsImludGVudCI6ImNoYXJnZSIsIm1ldGhvZCI6InRlbXBvIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJyZXF1ZXN0IjoiZXlKaGJXOTFiblFpT2lJeE1EQXdJaXdpWTNWeWNtVnVZM2tpT2lJd2VESXdZekF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKbGVIQnBjbVZ6SWpvaU1qQXlOUzB3TVMwd01WUXdNRG93TURvd01DNHdNREJhSWl3aWNtVmphWEJwWlc1MElqb2lNSGczTkRKa016VkRZelkyTXpSRE1EVXpNamt5TldFellqZzBORUpqT1dVM05UazFaamhtUlRBd0luMCJ9LCJwYXlsb2FkIjp7InNpZ25hdHVyZSI6IjB4YWJjMTIzIiwidHlwZSI6InRyYW5zYWN0aW9uIn19"`,
93
+ `"Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiIsImlkIjoiMGhucnlTUkRxV2Z0dGxESUpwdXhWNG1Kc1JKSVM3ZDdSam51ZnVvbkpPRSIsImludGVudCI6ImNoYXJnZSIsIm1ldGhvZCI6InRlbXBvIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJyZXF1ZXN0IjoiZXlKaGJXOTFiblFpT2lJeE1EQXdJaXdpWTNWeWNtVnVZM2tpT2lJd2VESXdZekF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKeVpXTnBjR2xsYm5RaU9pSXdlRGMwTW1Rek5VTmpOall6TkVNd05UTXlPVEkxWVROaU9EUTBRbU01WlRjMU9UVm1PR1pGTURBaWZRIn0sInBheWxvYWQiOnsic2lnbmF0dXJlIjoiMHhhYmMxMjMiLCJ0eXBlIjoidHJhbnNhY3Rpb24ifX0"`,
95
94
  )
96
95
  })
97
96
 
@@ -182,14 +181,13 @@ describe('mcp', () => {
182
181
  expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
183
182
  {
184
183
  "expires": "2025-01-01T00:00:00.000Z",
185
- "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
184
+ "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
186
185
  "intent": "charge",
187
186
  "method": "tempo",
188
187
  "realm": "api.example.com",
189
188
  "request": {
190
189
  "amount": "1000",
191
190
  "currency": "0x20c0000000000000000000000000000000000001",
192
- "expires": "2025-01-01T00:00:00.000Z",
193
191
  "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
194
192
  },
195
193
  }
@@ -239,14 +237,13 @@ describe('mcp', () => {
239
237
  "org.paymentauth/credential": {
240
238
  "challenge": {
241
239
  "expires": "2025-01-01T00:00:00.000Z",
242
- "id": "z8dUi61lViOj6cwh_ISb_5X8nBJF2OjTydcEap8wX0o",
240
+ "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
243
241
  "intent": "charge",
244
242
  "method": "tempo",
245
243
  "realm": "api.example.com",
246
244
  "request": {
247
245
  "amount": "1000",
248
246
  "currency": "0x20c0000000000000000000000000000000000001",
249
- "expires": "2025-01-01T00:00:00.000Z",
250
247
  "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
251
248
  },
252
249
  },
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import * as Fetch from './Fetch.js'
3
+
4
+ const noopMethod = {
5
+ name: 'test',
6
+ intent: 'test',
7
+ context: undefined,
8
+ createCredential: async () => 'credential',
9
+ } as any
10
+
11
+ function make402() {
12
+ const request = btoa(JSON.stringify({ amount: '1' }))
13
+ .replace(/\+/g, '-')
14
+ .replace(/\//g, '_')
15
+ .replace(/=+$/, '')
16
+ return new Response(null, {
17
+ status: 402,
18
+ headers: {
19
+ 'WWW-Authenticate': `Payment id="abc", realm="test", method="test", intent="test", request="${request}"`,
20
+ },
21
+ })
22
+ }
23
+
24
+ /** Returns a fetch wrapper and the init captured from the 402 retry call. */
25
+ function setup() {
26
+ const calls: (RequestInit | undefined)[] = []
27
+ let callCount = 0
28
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
29
+ calls.push(init)
30
+ callCount++
31
+ if (callCount === 1) return make402()
32
+ return new Response('OK', { status: 200 })
33
+ }
34
+ const fetch = Fetch.from({ fetch: mockFetch, methods: [noopMethod] })
35
+ return {
36
+ fetch,
37
+ /** Headers sent on the retry (second) request. */
38
+ retryHeaders: async (input: RequestInfo | URL, init?: RequestInit) => {
39
+ await fetch(input, init)
40
+ return (calls[1] as Record<string, unknown>)?.headers as Record<string, string>
41
+ },
42
+ }
43
+ }
44
+
45
+ describe('Fetch.from: browser header normalization', () => {
46
+ test('preserves Headers instance', async () => {
47
+ const { retryHeaders } = setup()
48
+ const h = await retryHeaders('https://example.com', {
49
+ headers: new Headers({ 'X-Custom': 'value', 'Content-Type': 'application/json' }),
50
+ })
51
+ expect(h['x-custom']).toBe('value')
52
+ expect(h['content-type']).toBe('application/json')
53
+ expect(h.Authorization).toBe('credential')
54
+ })
55
+
56
+ test('preserves header tuples', async () => {
57
+ const { retryHeaders } = setup()
58
+ const h = await retryHeaders('https://example.com', {
59
+ headers: [
60
+ ['X-Custom', 'value'],
61
+ ['Accept', 'application/json'],
62
+ ],
63
+ })
64
+ expect(h['X-Custom']).toBe('value')
65
+ expect(h.Accept).toBe('application/json')
66
+ expect(h.Authorization).toBe('credential')
67
+ })
68
+
69
+ test('replaces authorization case-insensitively', async () => {
70
+ const { retryHeaders } = setup()
71
+ const h = await retryHeaders('https://example.com', {
72
+ headers: { authorization: 'Bearer stale', 'X-Custom': 'value' },
73
+ })
74
+ expect(h.authorization).toBeUndefined()
75
+ expect(h.Authorization).toBe('credential')
76
+ expect(h['X-Custom']).toBe('value')
77
+ })
78
+
79
+ test('preserves plain object headers', async () => {
80
+ const { retryHeaders } = setup()
81
+ const h = await retryHeaders('https://example.com', { headers: { 'X-Custom': 'val' } })
82
+ expect(h['X-Custom']).toBe('val')
83
+ expect(h.Authorization).toBe('credential')
84
+ })
85
+
86
+ test('adds Authorization when no headers provided', async () => {
87
+ const { retryHeaders } = setup()
88
+ const h = await retryHeaders('https://example.com')
89
+ expect(h.Authorization).toBe('credential')
90
+ })
91
+ })
92
+
93
+ describe('Fetch.polyfill / restore: browser', () => {
94
+ test('restore is a no-op when polyfill was never called', () => {
95
+ const before = globalThis.fetch
96
+ Fetch.restore()
97
+ expect(globalThis.fetch).toBe(before)
98
+ })
99
+
100
+ test('restore reverts to original fetch', () => {
101
+ const original = globalThis.fetch
102
+ Fetch.polyfill({ methods: [noopMethod] })
103
+ expect(globalThis.fetch).not.toBe(original)
104
+ Fetch.restore()
105
+ expect(globalThis.fetch).toBe(original)
106
+ })
107
+
108
+ test('stacked polyfill calls preserve the true original', () => {
109
+ const original = globalThis.fetch
110
+ Fetch.polyfill({ methods: [noopMethod] })
111
+ Fetch.polyfill({ methods: [noopMethod] })
112
+ Fetch.restore()
113
+ expect(globalThis.fetch).toBe(original)
114
+ })
115
+
116
+ test('double restore does not clobber fetch', () => {
117
+ const original = globalThis.fetch
118
+ Fetch.polyfill({ methods: [noopMethod] })
119
+ Fetch.restore()
120
+ Fetch.restore()
121
+ expect(globalThis.fetch).toBe(original)
122
+ })
123
+
124
+ test('restore is a no-op when fetch was replaced externally', () => {
125
+ const original = globalThis.fetch
126
+ const external = vi.fn(
127
+ async () => new Response('external'),
128
+ ) as unknown as typeof globalThis.fetch
129
+ Fetch.polyfill({ methods: [noopMethod] })
130
+ globalThis.fetch = external
131
+ Fetch.restore()
132
+ expect(globalThis.fetch).toBe(external)
133
+ globalThis.fetch = original
134
+ })
135
+ })