mppx 0.5.11 → 0.5.13

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -350,7 +350,7 @@ function make402(overrides?: { method?: string; intent?: string }) {
350
350
  }
351
351
 
352
352
  describe('Fetch.from: init passthrough (non-402)', () => {
353
- test('passes unmodified init to underlying fetch for non-402 responses', async () => {
353
+ test('preserves init object identity while adding Accept-Payment', async () => {
354
354
  const receivedInits: (RequestInit | undefined)[] = []
355
355
  const mockFetch: typeof globalThis.fetch = async (_input, init) => {
356
356
  receivedInits.push(init)
@@ -371,6 +371,9 @@ describe('Fetch.from: init passthrough (non-402)', () => {
371
371
  await fetch('https://example.com/ws-upgrade', customInit)
372
372
 
373
373
  expect(receivedInits[0]).toBe(customInit)
374
+ const headers = new Headers(receivedInits[0]?.headers)
375
+ expect(headers.get('X-Custom')).toBe('value')
376
+ expect(headers.get('Accept-Payment')).toBe('test/test')
374
377
  })
375
378
 
376
379
  test('preserves extra properties on init for non-402 responses', async () => {
@@ -395,7 +398,8 @@ describe('Fetch.from: init passthrough (non-402)', () => {
395
398
 
396
399
  const received = receivedInits[0]!
397
400
  expect(received.method).toBe('GET')
398
- expect((received.headers as Record<string, string>).Authorization).toBe('Bearer token123')
401
+ expect(new Headers(received.headers).get('Authorization')).toBe('Bearer token123')
402
+ expect(new Headers(received.headers).get('Accept-Payment')).toBe('test/test')
399
403
  expect(received.signal).toBe(customInit.signal)
400
404
  })
401
405
 
@@ -412,7 +416,7 @@ describe('Fetch.from: init passthrough (non-402)', () => {
412
416
  })
413
417
 
414
418
  await fetch('https://example.com/api')
415
- expect(receivedInits[0]).toBeUndefined()
419
+ expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('test/test')
416
420
  })
417
421
 
418
422
  test('passes init with context through untouched', async () => {
@@ -431,9 +435,153 @@ describe('Fetch.from: init passthrough (non-402)', () => {
431
435
  await fetch('https://example.com/api', customInit as any)
432
436
 
433
437
  expect(receivedInits[0]).toBe(customInit)
438
+ expect((receivedInits[0] as Record<string, unknown>).context).toEqual({ account: '0xabc' })
439
+ expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('test/test')
434
440
  })
435
441
 
436
- test('preserves object identity across all non-402 status codes', async () => {
442
+ test('preserves Request-carried headers when injecting Accept-Payment', async () => {
443
+ const receivedInputs: (RequestInfo | URL)[] = []
444
+ const receivedInits: (RequestInit | undefined)[] = []
445
+ const mockFetch: typeof globalThis.fetch = async (input, init) => {
446
+ receivedInputs.push(input)
447
+ receivedInits.push(init)
448
+ return new Response('OK', { status: 200 })
449
+ }
450
+
451
+ const fetch = Fetch.from({
452
+ fetch: mockFetch,
453
+ methods: [noopMethod],
454
+ })
455
+
456
+ const request = new Request('https://example.com/api', {
457
+ headers: { Authorization: 'Bearer token123', 'X-Custom': 'value' },
458
+ })
459
+
460
+ await fetch(request)
461
+
462
+ expect(receivedInputs[0]).toBe(request)
463
+ const headers = new Headers(receivedInits[0]?.headers)
464
+ expect(headers.get('Authorization')).toBe('Bearer token123')
465
+ expect(headers.get('X-Custom')).toBe('value')
466
+ expect(headers.get('Accept-Payment')).toBe('test/test')
467
+ })
468
+
469
+ test('does not overwrite an explicit Accept-Payment header', async () => {
470
+ const receivedInits: (RequestInit | undefined)[] = []
471
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
472
+ receivedInits.push(init)
473
+ return new Response('OK', { status: 200 })
474
+ }
475
+
476
+ const fetch = Fetch.from({
477
+ fetch: mockFetch,
478
+ methods: [noopMethod],
479
+ })
480
+
481
+ await fetch('https://example.com/api', {
482
+ headers: { 'Accept-Payment': 'custom/charge;q=0.5' },
483
+ })
484
+
485
+ expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('custom/charge;q=0.5')
486
+ })
487
+
488
+ test('uses an explicit Accept-Payment header to reprioritize challenge selection', async () => {
489
+ let callCount = 0
490
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
491
+ callCount++
492
+ if (callCount === 1) {
493
+ expect(new Headers(init?.headers).get('Accept-Payment')).toBe(
494
+ 'stripe/charge, tempo/charge;q=0.1',
495
+ )
496
+
497
+ return new Response(null, {
498
+ status: 402,
499
+ headers: {
500
+ 'WWW-Authenticate': [
501
+ 'Payment id="tempo", realm="test", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
502
+ 'Payment id="stripe", realm="test", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
503
+ ].join(', '),
504
+ },
505
+ })
506
+ }
507
+
508
+ expect(new Headers(init?.headers).get('Authorization')).toBe('stripe-credential')
509
+ return new Response('OK', { status: 200 })
510
+ }
511
+
512
+ const fetch = Fetch.from({
513
+ fetch: mockFetch,
514
+ methods: [
515
+ {
516
+ name: 'tempo',
517
+ intent: 'charge',
518
+ context: undefined,
519
+ createCredential: async () => 'tempo-credential',
520
+ },
521
+ {
522
+ name: 'stripe',
523
+ intent: 'charge',
524
+ context: undefined,
525
+ createCredential: async () => 'stripe-credential',
526
+ },
527
+ ] as const,
528
+ })
529
+
530
+ const response = await fetch('https://example.com/api', {
531
+ headers: { 'Accept-Payment': 'stripe/charge, tempo/charge;q=0.1' },
532
+ })
533
+ expect(response.status).toBe(200)
534
+ })
535
+
536
+ test('applies an explicit method opt-out before broader wildcard preferences', async () => {
537
+ let callCount = 0
538
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
539
+ callCount++
540
+ if (callCount === 1) {
541
+ expect(new Headers(init?.headers).get('Accept-Payment')).toBe(
542
+ 'tempo/*;q=1, tempo/charge;q=0, stripe/*;q=0.5',
543
+ )
544
+
545
+ return new Response(null, {
546
+ status: 402,
547
+ headers: {
548
+ 'WWW-Authenticate': [
549
+ 'Payment id="tempo", realm="test", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
550
+ 'Payment id="stripe", realm="test", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
551
+ ].join(', '),
552
+ },
553
+ })
554
+ }
555
+
556
+ expect(new Headers(init?.headers).get('Authorization')).toBe('stripe-credential')
557
+ return new Response('OK', { status: 200 })
558
+ }
559
+
560
+ const fetch = Fetch.from({
561
+ fetch: mockFetch,
562
+ methods: [
563
+ {
564
+ name: 'tempo',
565
+ intent: 'charge',
566
+ context: undefined,
567
+ createCredential: async () => 'tempo-credential',
568
+ },
569
+ {
570
+ name: 'stripe',
571
+ intent: 'charge',
572
+ context: undefined,
573
+ createCredential: async () => 'stripe-credential',
574
+ },
575
+ ] as const,
576
+ })
577
+
578
+ const response = await fetch('https://example.com/api', {
579
+ headers: { 'Accept-Payment': 'tempo/*;q=1, tempo/charge;q=0, stripe/*;q=0.5' },
580
+ })
581
+ expect(response.status).toBe(200)
582
+ })
583
+
584
+ test('preserves non-header init fields across all non-402 status codes', async () => {
437
585
  for (const status of [200, 201, 204, 301, 400, 401, 403, 404, 500, 503]) {
438
586
  const receivedInits: (RequestInit | undefined)[] = []
439
587
  const mockFetch: typeof globalThis.fetch = async (_input, init) => {
@@ -448,7 +596,8 @@ describe('Fetch.from: init passthrough (non-402)', () => {
448
596
 
449
597
  const customInit = { method: 'GET' }
450
598
  await fetch('https://example.com/api', customInit)
451
- expect(receivedInits[0]).toBe(customInit)
599
+ expect(receivedInits[0]?.method).toBe(customInit.method)
600
+ expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('test/test')
452
601
  }
453
602
  })
454
603
  })
@@ -521,10 +670,11 @@ describe('Fetch.from: 402 retry path', () => {
521
670
  })
522
671
 
523
672
  const retryInit = calls[1]!.init as Record<string, unknown>
524
- const headers = retryInit.headers as Record<string, string>
525
- expect(headers['X-Custom']).toBe('value')
526
- expect(headers['Content-Type']).toBe('application/json')
527
- expect(headers.Authorization).toBe('credential')
673
+ const headers = new Headers(retryInit.headers as HeadersInit)
674
+ expect(headers.get('X-Custom')).toBe('value')
675
+ expect(headers.get('Content-Type')).toBe('application/json')
676
+ expect(headers.get('Accept-Payment')).toBe('test/test')
677
+ expect(headers.get('Authorization')).toBe('credential')
528
678
  })
529
679
 
530
680
  test('preserves method and other init properties on retry', async () => {
@@ -575,7 +725,154 @@ describe('Fetch.from: 402 retry path', () => {
575
725
 
576
726
  expect(calls).toHaveLength(2)
577
727
  const retryInit = calls[1]!.init as Record<string, unknown>
578
- expect(retryInit.headers).toEqual({ Authorization: 'credential' })
728
+ const headers = new Headers(retryInit.headers as HeadersInit)
729
+ expect(headers.get('Accept-Payment')).toBe('test/test')
730
+ expect(headers.get('Authorization')).toBe('credential')
731
+ })
732
+
733
+ test('preserves Request-carried headers on retry after injecting Accept-Payment', async () => {
734
+ let callCount = 0
735
+ const calls: { init: RequestInit | undefined; input: RequestInfo | URL }[] = []
736
+ const mockFetch: typeof globalThis.fetch = async (input, init) => {
737
+ calls.push({ init, input })
738
+ callCount++
739
+ if (callCount === 1) return make402()
740
+ return new Response('OK', { status: 200 })
741
+ }
742
+
743
+ const fetch = Fetch.from({
744
+ fetch: mockFetch,
745
+ methods: [noopMethod],
746
+ })
747
+
748
+ const request = new Request('https://example.com/api', {
749
+ headers: { Authorization: 'Bearer token123', 'X-Custom': 'value' },
750
+ })
751
+
752
+ await fetch(request)
753
+
754
+ expect(calls[0]?.input).toBe(request)
755
+ expect(calls[1]?.input).toBe(request)
756
+ const headers = new Headers(calls[1]?.init?.headers)
757
+ expect(headers.get('X-Custom')).toBe('value')
758
+ expect(headers.get('Accept-Payment')).toBe('test/test')
759
+ expect(headers.get('Authorization')).toBe('credential')
760
+ })
761
+
762
+ test('selects the highest-ranked supported challenge', async () => {
763
+ let callCount = 0
764
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
765
+ callCount++
766
+ if (callCount === 1) {
767
+ expect(new Headers(init?.headers).get('Accept-Payment')).toBe(
768
+ 'tempo/charge, stripe/charge;q=0.5',
769
+ )
770
+
771
+ return new Response(null, {
772
+ status: 402,
773
+ headers: {
774
+ 'WWW-Authenticate': [
775
+ 'Payment id="stripe", realm="test", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
776
+ 'Payment id="tempo", realm="test", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
777
+ ].join(', '),
778
+ },
779
+ })
780
+ }
781
+
782
+ expect(new Headers(init?.headers).get('Authorization')).toBe('tempo-credential')
783
+ return new Response('OK', { status: 200 })
784
+ }
785
+
786
+ const fetch = Fetch.from({
787
+ fetch: mockFetch,
788
+ methods: [
789
+ {
790
+ name: 'tempo',
791
+ intent: 'charge',
792
+ context: undefined,
793
+ createCredential: async () => 'tempo-credential',
794
+ },
795
+ {
796
+ name: 'stripe',
797
+ intent: 'charge',
798
+ context: undefined,
799
+ createCredential: async () => 'stripe-credential',
800
+ },
801
+ ] as const,
802
+ acceptPayment: {
803
+ definition: { 'stripe/charge': 0.5 },
804
+ entries: [
805
+ { intent: 'charge', method: 'tempo', q: 1, index: 0 },
806
+ { intent: 'charge', method: 'stripe', q: 0.5, index: 1 },
807
+ ],
808
+ header: 'tempo/charge, stripe/charge;q=0.5',
809
+ keys: {
810
+ stripe: { charge: 'stripe/charge' },
811
+ tempo: { charge: 'tempo/charge' },
812
+ },
813
+ },
814
+ })
815
+
816
+ const response = await fetch('https://example.com/api')
817
+ expect(response.status).toBe(200)
818
+ })
819
+
820
+ test('falls back to configured preferences when explicit Accept-Payment is invalid', async () => {
821
+ let callCount = 0
822
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
823
+ callCount++
824
+ if (callCount === 1) {
825
+ expect(new Headers(init?.headers).get('Accept-Payment')).toBe('not a valid header')
826
+
827
+ return new Response(null, {
828
+ status: 402,
829
+ headers: {
830
+ 'WWW-Authenticate': [
831
+ 'Payment id="stripe", realm="test", method="stripe", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
832
+ 'Payment id="tempo", realm="test", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxIn0"',
833
+ ].join(', '),
834
+ },
835
+ })
836
+ }
837
+
838
+ expect(new Headers(init?.headers).get('Authorization')).toBe('tempo-credential')
839
+ return new Response('OK', { status: 200 })
840
+ }
841
+
842
+ const fetch = Fetch.from({
843
+ fetch: mockFetch,
844
+ methods: [
845
+ {
846
+ name: 'tempo',
847
+ intent: 'charge',
848
+ context: undefined,
849
+ createCredential: async () => 'tempo-credential',
850
+ },
851
+ {
852
+ name: 'stripe',
853
+ intent: 'charge',
854
+ context: undefined,
855
+ createCredential: async () => 'stripe-credential',
856
+ },
857
+ ] as const,
858
+ acceptPayment: {
859
+ definition: { 'stripe/charge': 0.5 },
860
+ entries: [
861
+ { intent: 'charge', method: 'tempo', q: 1, index: 0 },
862
+ { intent: 'charge', method: 'stripe', q: 0.5, index: 1 },
863
+ ],
864
+ header: 'tempo/charge, stripe/charge;q=0.5',
865
+ keys: {
866
+ stripe: { charge: 'stripe/charge' },
867
+ tempo: { charge: 'tempo/charge' },
868
+ },
869
+ },
870
+ })
871
+
872
+ const response = await fetch('https://example.com/api', {
873
+ headers: { 'Accept-Payment': 'not a valid header' },
874
+ })
875
+ expect(response.status).toBe(200)
579
876
  })
580
877
 
581
878
  test('throws when no matching method for 402 challenge', async () => {
@@ -1,4 +1,5 @@
1
1
  import * as Challenge from '../../Challenge.js'
2
+ import * as AcceptPayment from '../../internal/AcceptPayment.js'
2
3
  import type * as Method from '../../Method.js'
3
4
  import type * as z from '../../zod.js'
4
5
 
@@ -37,41 +38,42 @@ let originalFetch: typeof globalThis.fetch | undefined
37
38
  export function from<const methods extends readonly Method.AnyClient[]>(
38
39
  config: from.Config<methods>,
39
40
  ): from.Fetch<methods> {
40
- const { fetch = globalThis.fetch, methods, onChallenge } = config
41
+ const { acceptPayment, fetch = globalThis.fetch, methods, onChallenge } = config
42
+ const resolvedAcceptPayment = acceptPayment ?? AcceptPayment.resolve(methods)
41
43
  // Always operate on the true underlying fetch to avoid wrapper-on-wrapper stacking,
42
44
  // which can duplicate retries and make restore semantics fragile.
43
45
  const baseFetch = unwrapFetch(fetch)
44
46
 
45
47
  const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
46
- // Pass init through untouched to preserve object identity for non-402 responses.
47
- const response = await baseFetch(input, init)
48
+ const callerHeaders = getCallerHeaders(input, init?.headers)
49
+ const hasExplicitAcceptPayment = callerHeaders.has('Accept-Payment')
50
+ const paymentPreferences = resolvePaymentPreferences(callerHeaders, resolvedAcceptPayment)
51
+ const initialRequest = prepareInitialRequest(
52
+ input,
53
+ init,
54
+ callerHeaders,
55
+ paymentPreferences.header,
56
+ hasExplicitAcceptPayment,
57
+ )
58
+ const response = await baseFetch(initialRequest.input, initialRequest.init)
48
59
 
49
60
  if (response.status !== 402) return response
50
61
 
51
62
  // Only extract context for payment handling after confirming 402.
52
63
  const context = (init as Record<string, unknown> | undefined)?.context
53
- const { context: _, ...fetchInit } = (init ?? {}) as Record<string, unknown>
64
+ const { context: _, ...fetchInit } = (initialRequest.init ?? {}) as Record<string, unknown>
54
65
 
55
66
  // Parse all challenges from the response (supports merged WWW-Authenticate headers).
56
- // Match in client preference order: iterate the client's methods array and pick the
57
- // first method that has a matching challenge, so the client controls priority.
58
67
  const challenges = Challenge.fromResponseList(response)
59
68
 
60
- let challenge: Challenge.Challenge | undefined
61
- let mi: (typeof methods)[number] | undefined
62
- for (const m of methods) {
63
- const match = challenges.find((c) => c.method === m.name && c.intent === m.intent)
64
- if (match) {
65
- challenge = match
66
- mi = m
67
- break
68
- }
69
- }
70
- if (!challenge || !mi)
69
+ const selected = AcceptPayment.selectChallenge(challenges, methods, paymentPreferences.entries)
70
+ if (!selected)
71
71
  throw new Error(
72
72
  `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
73
73
  )
74
74
 
75
+ const { challenge, method: mi } = selected
76
+
75
77
  const onChallengeCredential = onChallenge
76
78
  ? await onChallenge(challenge, {
77
79
  createCredential: async (overrideContext?: AnyContextFor<methods>) =>
@@ -81,9 +83,9 @@ export function from<const methods extends readonly Method.AnyClient[]>(
81
83
  const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
82
84
  validateCredentialHeaderValue(credential)
83
85
 
84
- return baseFetch(input, {
86
+ return baseFetch(initialRequest.input, {
85
87
  ...fetchInit,
86
- headers: withAuthorizationHeader(fetchInit.headers, credential),
88
+ headers: withAuthorizationHeader(initialRequest.headers, credential),
87
89
  })
88
90
  }
89
91
 
@@ -104,6 +106,8 @@ type AnyContextFor<methods extends readonly Method.AnyClient[]> = {
104
106
 
105
107
  export declare namespace from {
106
108
  type Config<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = {
109
+ /** Resolved `Accept-Payment` header and selection preferences. */
110
+ acceptPayment?: AcceptPayment.Resolved<methods> | undefined
107
111
  /** Custom fetch function to wrap. Defaults to `globalThis.fetch`. */
108
112
  fetch?: typeof globalThis.fetch
109
113
  /** Array of methods to use. */
@@ -218,6 +222,47 @@ function withAuthorizationHeader(headers: unknown, credential: string): Record<s
218
222
  return normalized
219
223
  }
220
224
 
225
+ /** @internal */
226
+ function prepareInitialRequest<methods extends readonly Method.AnyClient[]>(
227
+ input: RequestInfo | URL,
228
+ init: from.RequestInit<methods> | undefined,
229
+ callerHeaders: Headers,
230
+ header: string,
231
+ hasExplicitAcceptPayment: boolean,
232
+ ): { headers: Headers; init: from.RequestInit<methods> | undefined; input: RequestInfo | URL } {
233
+ const shouldInjectAcceptPayment = Boolean(header) && !hasExplicitAcceptPayment
234
+ if (!shouldInjectAcceptPayment) return { headers: callerHeaders, init, input }
235
+
236
+ const headers = new Headers(input instanceof Request ? input.headers : undefined)
237
+ callerHeaders.forEach((value, key) => {
238
+ headers.set(key, value)
239
+ })
240
+ headers.set('Accept-Payment', header)
241
+
242
+ if (init) {
243
+ // Preserve init identity for callers like websocket upgrade helpers that
244
+ // depend on the original RequestInit object reaching the underlying fetch.
245
+ ;(init as from.RequestInit<methods> & { headers?: HeadersInit }).headers = headers
246
+ return {
247
+ headers,
248
+ init,
249
+ input,
250
+ }
251
+ }
252
+
253
+ return {
254
+ headers,
255
+ init: shouldInjectAcceptPayment ? ({ headers } as from.RequestInit<methods>) : undefined,
256
+ input,
257
+ }
258
+ }
259
+
260
+ /** @internal */
261
+ function getCallerHeaders(input: RequestInfo | URL, headers: HeadersInit | undefined): Headers {
262
+ if (headers) return new Headers(headers)
263
+ return new Headers(input instanceof Request ? input.headers : undefined)
264
+ }
265
+
221
266
  /** @internal */
222
267
  function unwrapFetch(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
223
268
  let current = fetch as WrappedFetch
@@ -251,3 +296,24 @@ async function resolveCredential(
251
296
  parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
252
297
  )
253
298
  }
299
+
300
+ function resolvePaymentPreferences<methods extends readonly Method.AnyClient[]>(
301
+ headers: Headers,
302
+ acceptPayment: AcceptPayment.Resolved<methods>,
303
+ ): AcceptPayment.Resolved<methods> {
304
+ const header = headers.get('Accept-Payment')
305
+ if (!header) return acceptPayment
306
+
307
+ try {
308
+ return {
309
+ ...acceptPayment,
310
+ entries: AcceptPayment.parse(header),
311
+ header,
312
+ }
313
+ } catch {
314
+ // Fail open for explicit malformed headers: preserve the caller's header on
315
+ // the wire, but continue automatic challenge selection with configured
316
+ // defaults instead of throwing from the wrapper.
317
+ return acceptPayment
318
+ }
319
+ }