mppx 0.8.0 → 0.8.2

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 (89) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/Errors.d.ts +13 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +12 -0
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +35 -4
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/internal.d.ts +1 -0
  11. package/dist/cli/internal.d.ts.map +1 -1
  12. package/dist/cli/internal.js +1 -15
  13. package/dist/cli/internal.js.map +1 -1
  14. package/dist/cli/plugins/tempo.d.ts +15 -0
  15. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  16. package/dist/cli/plugins/tempo.js +124 -14
  17. package/dist/cli/plugins/tempo.js.map +1 -1
  18. package/dist/client/Mppx.d.ts +2 -0
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +2 -1
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/Transport.d.ts.map +1 -1
  23. package/dist/client/Transport.js +11 -12
  24. package/dist/client/Transport.js.map +1 -1
  25. package/dist/client/internal/Fetch.d.ts +2 -0
  26. package/dist/client/internal/Fetch.d.ts.map +1 -1
  27. package/dist/client/internal/Fetch.js +55 -50
  28. package/dist/client/internal/Fetch.js.map +1 -1
  29. package/dist/client/internal/protocols/Mcp.d.ts +6 -0
  30. package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
  31. package/dist/client/internal/protocols/Mcp.js +28 -10
  32. package/dist/client/internal/protocols/Mcp.js.map +1 -1
  33. package/dist/client/internal/protocols/X402.d.ts.map +1 -1
  34. package/dist/client/internal/protocols/X402.js +18 -12
  35. package/dist/client/internal/protocols/X402.js.map +1 -1
  36. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  37. package/dist/stripe/server/internal/html.gen.js +1 -1
  38. package/dist/tempo/internal/fee-payer.d.ts +17 -0
  39. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  40. package/dist/tempo/internal/fee-payer.js +105 -25
  41. package/dist/tempo/internal/fee-payer.js.map +1 -1
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +4 -0
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.js +1 -1
  48. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  49. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  50. package/dist/tempo/session/client/SessionManager.js +47 -5
  51. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  52. package/dist/tempo/session/server/CredentialVerification.d.ts +6 -0
  53. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  54. package/dist/tempo/session/server/CredentialVerification.js +13 -0
  55. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  56. package/dist/x402/Header.d.ts +10 -1
  57. package/dist/x402/Header.d.ts.map +1 -1
  58. package/dist/x402/Header.js +27 -1
  59. package/dist/x402/Header.js.map +1 -1
  60. package/package.json +3 -3
  61. package/src/Errors.test.ts +28 -0
  62. package/src/Errors.ts +19 -0
  63. package/src/cli/cli.test.ts +194 -0
  64. package/src/cli/cli.ts +43 -7
  65. package/src/cli/config.test.ts +13 -7
  66. package/src/cli/internal.ts +1 -16
  67. package/src/cli/plugins/tempo.ts +160 -17
  68. package/src/client/Mppx.test-d.ts +9 -0
  69. package/src/client/Mppx.test.ts +41 -0
  70. package/src/client/Mppx.ts +4 -0
  71. package/src/client/Transport.test.ts +124 -0
  72. package/src/client/Transport.ts +11 -9
  73. package/src/client/internal/Fetch.test-d.ts +9 -0
  74. package/src/client/internal/Fetch.test.ts +186 -4
  75. package/src/client/internal/Fetch.ts +85 -76
  76. package/src/client/internal/protocols/Mcp.ts +39 -15
  77. package/src/client/internal/protocols/X402.ts +9 -5
  78. package/src/server/Mppx.test.ts +3 -0
  79. package/src/stripe/server/internal/html.gen.ts +1 -1
  80. package/src/tempo/internal/fee-payer.test.ts +130 -0
  81. package/src/tempo/internal/fee-payer.ts +157 -36
  82. package/src/tempo/server/Charge.ts +4 -0
  83. package/src/tempo/server/internal/html/package.json +2 -2
  84. package/src/tempo/server/internal/html.gen.ts +1 -1
  85. package/src/tempo/session/client/SessionManager.test.ts +57 -4
  86. package/src/tempo/session/client/SessionManager.ts +50 -6
  87. package/src/tempo/session/server/CredentialVerification.test.ts +36 -0
  88. package/src/tempo/session/server/CredentialVerification.ts +18 -0
  89. package/src/x402/Header.ts +45 -0
@@ -2,6 +2,7 @@ import { Challenge, Credential, Mcp } from 'mppx'
2
2
  import { Transport } from 'mppx/client'
3
3
  import { Methods } from 'mppx/tempo'
4
4
  import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402'
5
+ import { Base64 } from 'ox'
5
6
  import { describe, expect, test } from 'vp/test'
6
7
 
7
8
  import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js'
@@ -43,6 +44,25 @@ const x402PaymentRequired = {
43
44
  x402Version: 2,
44
45
  } satisfies PaymentRequired
45
46
 
47
+ const unsupportedSolanaAccept = {
48
+ amount: '10000',
49
+ asset: 'USDC',
50
+ maxTimeoutSeconds: 60,
51
+ network: 'solana:mainnet',
52
+ payTo: '9xQeWvG816bUx9EPjHmaT23yvVM2ZWdYqPxfowV5n2kg',
53
+ scheme: 'exact',
54
+ }
55
+
56
+ const unsupportedSchemeAccept = {
57
+ ...x402PaymentRequired.accepts[0]!,
58
+ scheme: 'upto',
59
+ }
60
+
61
+ // Strict x402 encoding rejects unsupported accepts before the transport can filter them.
62
+ function encodeRawPaymentRequiredHeader(value: unknown): string {
63
+ return Base64.fromString(JSON.stringify(value))
64
+ }
65
+
46
66
  describe('http', () => {
47
67
  describe('isPaymentRequired', () => {
48
68
  test.each([
@@ -140,6 +160,28 @@ describe('http', () => {
140
160
  }),
141
161
  name: 'Payment auth and x402 challenges when both are present',
142
162
  },
163
+ {
164
+ expectedIds: [`${x402_Types.syntheticChallengeIdPrefix}0`],
165
+ expectedMethods: [x402_Types.paymentMethod],
166
+ headers: () => ({
167
+ 'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
168
+ ...x402PaymentRequired,
169
+ accepts: [x402PaymentRequired.accepts[0]!, unsupportedSolanaAccept],
170
+ }),
171
+ }),
172
+ name: 'x402 challenges filtered from mixed EVM and Solana accepts',
173
+ },
174
+ {
175
+ expectedIds: [`${x402_Types.syntheticChallengeIdPrefix}0`],
176
+ expectedMethods: [x402_Types.paymentMethod],
177
+ headers: () => ({
178
+ 'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
179
+ ...x402PaymentRequired,
180
+ accepts: [x402PaymentRequired.accepts[0]!, unsupportedSchemeAccept],
181
+ }),
182
+ }),
183
+ name: 'x402 challenges filtered from mixed exact and non-exact accepts',
184
+ },
143
185
  ])('returns $name', async ({ expectedIds, expectedMethods, headers }) => {
144
186
  const transport = Transport.http()
145
187
  const response = new Response(null, {
@@ -151,6 +193,42 @@ describe('http', () => {
151
193
  expect(challenges.map((entry) => entry.id)).toEqual(expectedIds)
152
194
  expect(challenges.map((entry) => entry.method)).toEqual(expectedMethods)
153
195
  })
196
+
197
+ test('returns Payment auth challenges when x402 accepts are unsupported', async () => {
198
+ const transport = Transport.http()
199
+ const response = new Response(null, {
200
+ status: 402,
201
+ headers: {
202
+ 'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
203
+ ...x402PaymentRequired,
204
+ accepts: [unsupportedSolanaAccept],
205
+ }),
206
+ 'WWW-Authenticate': Challenge.serialize(challenge),
207
+ },
208
+ })
209
+
210
+ const challenges = (await transport.getChallenges?.(response)) ?? []
211
+
212
+ expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
213
+ expect(challenges.map((entry) => entry.method)).toEqual(['tempo'])
214
+ })
215
+
216
+ test('returns no challenges when only unsupported x402 accepts are present', async () => {
217
+ const transport = Transport.http()
218
+ const response = new Response(null, {
219
+ status: 402,
220
+ headers: {
221
+ 'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
222
+ ...x402PaymentRequired,
223
+ accepts: [unsupportedSolanaAccept],
224
+ }),
225
+ },
226
+ })
227
+
228
+ const challenges = (await transport.getChallenges?.(response)) ?? []
229
+ expect(challenges).toEqual([])
230
+ expect(() => transport.getChallenge(response)).toThrow('No challenge in response.')
231
+ })
154
232
  })
155
233
 
156
234
  describe('setCredential', () => {
@@ -322,8 +400,24 @@ describe('http (MCP-over-HTTP)', () => {
322
400
  data: { challenges: [challenge] },
323
401
  },
324
402
  }
403
+ const resultMessage = {
404
+ jsonrpc: '2.0',
405
+ id: 1,
406
+ result: {
407
+ content: [{ type: 'text', text: 'Payment Required' }],
408
+ isError: true,
409
+ _meta: {
410
+ [Mcp.paymentRequiredMetaKey]: {
411
+ httpStatus: 402,
412
+ challenges: [challenge],
413
+ },
414
+ },
415
+ },
416
+ }
325
417
  const jsonBody = () =>
326
418
  new Response(JSON.stringify(errorMessage), { headers: { 'content-type': 'application/json' } })
419
+ const jsonResultBody = () =>
420
+ new Response(JSON.stringify(resultMessage), { headers: { 'content-type': 'application/json' } })
327
421
  const sseBody = () =>
328
422
  new Response(`event: message\ndata: ${JSON.stringify(errorMessage)}\n\n`, {
329
423
  headers: { 'content-type': 'text/event-stream' },
@@ -332,6 +426,9 @@ describe('http (MCP-over-HTTP)', () => {
332
426
  test('detects -32042 in a JSON body (JSON-RPC request)', async () => {
333
427
  expect(await Transport.http().isPaymentRequired(jsonBody(), jsonRpcRequest)).toBe(true)
334
428
  })
429
+ test('detects payment-required metadata in a JSON-RPC result', async () => {
430
+ expect(await Transport.http().isPaymentRequired(jsonResultBody(), jsonRpcRequest)).toBe(true)
431
+ })
335
432
  test('ignores a JSON-RPC response for a different request id', async () => {
336
433
  const response = new Response(JSON.stringify({ ...errorMessage, id: 2 }), {
337
434
  headers: { 'content-type': 'application/json' },
@@ -403,6 +500,10 @@ describe('http (MCP-over-HTTP)', () => {
403
500
  const challenges = await Transport.http().getChallenges!(sseBody(), jsonRpcRequest)
404
501
  expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
405
502
  })
503
+ test('getChallenges extracts an MCP result metadata challenge', async () => {
504
+ const challenges = await Transport.http().getChallenges!(jsonResultBody(), jsonRpcRequest)
505
+ expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
506
+ })
406
507
  test('setCredential routes the MCP challenge into the JSON-RPC _meta', async () => {
407
508
  const transport = Transport.http()
408
509
  const [mcpChallenge] = await transport.getChallenges!(sseBody(), jsonRpcRequest)
@@ -473,6 +574,20 @@ describe('mcp', () => {
473
574
  },
474
575
  },
475
576
  }
577
+ const mcpResult: Mcp.Response = {
578
+ jsonrpc: '2.0',
579
+ id: 1,
580
+ result: {
581
+ content: [],
582
+ isError: true,
583
+ _meta: {
584
+ [Mcp.paymentRequiredMetaKey]: {
585
+ httpStatus: 402,
586
+ challenges: [challenge],
587
+ },
588
+ },
589
+ },
590
+ }
476
591
 
477
592
  test('extracts payment-required challenges from JSON-RPC errors', async () => {
478
593
  const transport = Transport.mcp()
@@ -483,6 +598,15 @@ describe('mcp', () => {
483
598
  ])
484
599
  })
485
600
 
601
+ test('extracts payment-required challenges from tool result metadata', async () => {
602
+ const transport = Transport.mcp()
603
+
604
+ expect(await transport.isPaymentRequired(mcpResult)).toBe(true)
605
+ expect((await transport.getChallenges?.(mcpResult))?.map((entry) => entry.id)).toEqual([
606
+ challenge.id,
607
+ ])
608
+ })
609
+
486
610
  test('sets credentials in JSON-RPC _meta', () => {
487
611
  const result = Transport.mcp().setCredential(mcpRequest, Credential.serialize(credential))
488
612
 
@@ -1,7 +1,7 @@
1
1
  import * as Challenge from '../Challenge.js'
2
2
  import * as Credential from '../Credential.js'
3
3
  import * as Mcp from '../Mcp.js'
4
- import { mcp as mcpProtocol } from './internal/protocols/Mcp.js'
4
+ import { mcp as mcpProtocol, paymentRequiredData } from './internal/protocols/Mcp.js'
5
5
  import { mpp as mppProtocol } from './internal/protocols/Mpp.js'
6
6
  import type { Protocol } from './internal/protocols/Protocol.js'
7
7
  import { paymentRequiredStatus } from './internal/protocols/Shared.js'
@@ -144,6 +144,12 @@ export function http(): Transport<RequestInit, Response> {
144
144
  })
145
145
  }
146
146
 
147
+ function mcpPaymentRequiredChallenges(response: Mcp.Response) {
148
+ const data = paymentRequiredData(response)
149
+ if (!data) throw new Error('No challenge in response.')
150
+ return data.challenges
151
+ }
152
+
147
153
  /**
148
154
  * MCP protocol transport for direct JSON-RPC objects.
149
155
  *
@@ -155,20 +161,16 @@ export function mcp() {
155
161
  name: 'mcp',
156
162
 
157
163
  isPaymentRequired(response) {
158
- return 'error' in response && response.error?.code === Mcp.paymentRequiredCode
164
+ return !!paymentRequiredData(response)
159
165
  },
160
166
 
161
167
  getChallenges(response) {
162
- if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
163
- const challenges = response.error.data?.challenges
164
- if (!challenges?.length) throw new Error('No challenge in error response.')
165
- return challenges
168
+ return mcpPaymentRequiredChallenges(response)
166
169
  },
167
170
 
168
171
  getChallenge(response) {
169
- if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
170
- const challenge = response.error.data?.challenges[0]
171
- if (!challenge) throw new Error('No challenge in error response.')
172
+ const challenge = mcpPaymentRequiredChallenges(response)[0]
173
+ if (!challenge) throw new Error('No challenge in response.')
172
174
  return challenge
173
175
  },
174
176
 
@@ -57,6 +57,15 @@ describe('Fetch.from', () => {
57
57
  })
58
58
  })
59
59
 
60
+ test('behavior: accepts payment retry cap', () => {
61
+ const fetch = Fetch.from({
62
+ maxPaymentRetries: 1,
63
+ methods: [charge()],
64
+ })
65
+
66
+ expectTypeOf(fetch).toBeFunction()
67
+ })
68
+
60
69
  test('behavior: events infer payload types from methods', () => {
61
70
  const method = charge()
62
71
  const dispatcher = Fetch.createEventDispatcher<[typeof method]>()
@@ -2,6 +2,7 @@ import { Challenge, Credential, Errors, Mcp, Receipt } from 'mppx'
2
2
  import { tempo } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402'
5
+ import { Base64 } from 'ox'
5
6
  import { createClient, defineChain } from 'viem'
6
7
  import { describe, expect, test, vi } from 'vp/test'
7
8
  import * as Http from '~test/Http.js'
@@ -351,7 +352,8 @@ const x402PaymentRequired = {
351
352
  } satisfies PaymentRequired
352
353
 
353
354
  /** Builds a valid 402 response with a WWW-Authenticate header. */
354
- function make402(overrides?: { expires?: string; intent?: string; method?: string }) {
355
+ function make402(overrides?: { expires?: string; id?: string; intent?: string; method?: string }) {
356
+ const id = overrides?.id ?? 'abc'
355
357
  const method = overrides?.method ?? 'test'
356
358
  const intent = overrides?.intent ?? 'test'
357
359
  const expires = overrides?.expires ? `, expires="${overrides.expires}"` : ''
@@ -359,7 +361,7 @@ function make402(overrides?: { expires?: string; intent?: string; method?: strin
359
361
  .replace(/\+/g, '-')
360
362
  .replace(/\//g, '_')
361
363
  .replace(/=+$/, '')
362
- const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
364
+ const header = `Payment id="${id}", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
363
365
  return new Response(null, {
364
366
  status: 402,
365
367
  headers: { 'WWW-Authenticate': header },
@@ -379,6 +381,11 @@ function makeCombined402() {
379
381
  })
380
382
  }
381
383
 
384
+ // Strict x402 encoding rejects unsupported accepts before the transport can filter them.
385
+ function encodeRawPaymentRequiredHeader(value: unknown): string {
386
+ return Base64.fromString(JSON.stringify(value))
387
+ }
388
+
382
389
  describe('Fetch.from: init passthrough (non-402)', () => {
383
390
  test('preserves init object identity while adding Accept-Payment', async () => {
384
391
  const receivedInits: (RequestInit | undefined)[] = []
@@ -780,6 +787,69 @@ describe('Fetch.from: 402 retry path', () => {
780
787
  expect(calls).toHaveLength(2)
781
788
  })
782
789
 
790
+ test('settles MCP-over-HTTP result metadata payment challenges at the fetch boundary', async () => {
791
+ const mcpChallenge = Challenge.from({
792
+ id: 'mcp-result-challenge',
793
+ intent: 'test',
794
+ method: 'test',
795
+ realm: 'test',
796
+ request: { amount: '1' },
797
+ })
798
+ const method = {
799
+ ...noopMethod,
800
+ createCredential: async ({ challenge }: { challenge: Challenge.Challenge }) =>
801
+ Credential.serialize({ challenge, payload: { source: 'mcp-result' } }),
802
+ }
803
+ const initialBody = JSON.stringify({
804
+ jsonrpc: '2.0',
805
+ id: 1,
806
+ method: 'tools/call',
807
+ params: { name: 'paid-tool' },
808
+ })
809
+ let callCount = 0
810
+ const calls: { init: RequestInit | undefined }[] = []
811
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
812
+ calls.push({ init })
813
+ callCount++
814
+ if (callCount === 1)
815
+ return Response.json({
816
+ jsonrpc: '2.0',
817
+ id: 1,
818
+ result: {
819
+ content: [{ type: 'text', text: 'Payment Required' }],
820
+ isError: true,
821
+ _meta: {
822
+ [Mcp.paymentRequiredMetaKey]: {
823
+ httpStatus: 402,
824
+ challenges: [mcpChallenge],
825
+ },
826
+ },
827
+ },
828
+ })
829
+
830
+ const body = JSON.parse(init?.body as string)
831
+ expect(new Headers(init?.headers).get('Authorization')).toBeNull()
832
+ expect(body.params['_meta'][Mcp.credentialMetaKey]).toMatchObject({
833
+ payload: { source: 'mcp-result' },
834
+ })
835
+ return Response.json({ ok: true })
836
+ }
837
+
838
+ const fetch = Fetch.from({
839
+ fetch: mockFetch,
840
+ methods: [method],
841
+ })
842
+
843
+ const response = await fetch('https://example.com/mcp', {
844
+ method: 'POST',
845
+ headers: { accept: 'application/json, text/event-stream' },
846
+ body: initialBody,
847
+ })
848
+
849
+ expect(response.status).toBe(200)
850
+ expect(calls).toHaveLength(2)
851
+ })
852
+
783
853
  test('settles MCP-over-HTTP when the JSON-RPC request body is carried by Request input', async () => {
784
854
  const mcpChallenge = Challenge.from({
785
855
  id: 'mcp-request-input-challenge',
@@ -1356,6 +1426,63 @@ describe('Fetch.from: 402 retry path', () => {
1356
1426
  expect(response.status).toBe(200)
1357
1427
  })
1358
1428
 
1429
+ test('ignores unsupported x402 accepts when selecting a Payment auth challenge', async () => {
1430
+ let callCount = 0
1431
+ const createCredential = vi.fn(async () => 'tempo-credential')
1432
+ const tempoChallenge = Challenge.from({
1433
+ id: 'tempo',
1434
+ realm: 'test',
1435
+ method: 'tempo',
1436
+ intent: 'charge',
1437
+ request: { amount: '1' },
1438
+ })
1439
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
1440
+ callCount++
1441
+ if (callCount === 1) {
1442
+ return new Response(null, {
1443
+ status: 402,
1444
+ headers: {
1445
+ [x402_Types.paymentRequiredHeader]: encodeRawPaymentRequiredHeader({
1446
+ accepts: [
1447
+ {
1448
+ amount: '10000',
1449
+ asset: 'USDC',
1450
+ maxTimeoutSeconds: 60,
1451
+ network: 'solana:mainnet',
1452
+ payTo: '9xQeWvG816bUx9EPjHmaT23yvVM2ZWdYqPxfowV5n2kg',
1453
+ scheme: 'exact',
1454
+ },
1455
+ ],
1456
+ resource: { url: 'https://example.com/api' },
1457
+ x402Version: 2,
1458
+ }),
1459
+ 'WWW-Authenticate': Challenge.serialize(tempoChallenge),
1460
+ },
1461
+ })
1462
+ }
1463
+
1464
+ expect(new Headers(init?.headers).get('Authorization')).toBe('tempo-credential')
1465
+ return new Response('OK', { status: 200 })
1466
+ }
1467
+
1468
+ const fetch = Fetch.from({
1469
+ fetch: mockFetch,
1470
+ methods: [
1471
+ {
1472
+ name: 'tempo',
1473
+ intent: 'charge',
1474
+ context: undefined,
1475
+ createCredential,
1476
+ },
1477
+ ] as const,
1478
+ })
1479
+
1480
+ const response = await fetch('https://example.com/api')
1481
+
1482
+ expect(response.status).toBe(200)
1483
+ expect(createCredential).toHaveBeenCalledOnce()
1484
+ })
1485
+
1359
1486
  test('orderChallenges filters and sorts supported challenges before signing', async () => {
1360
1487
  let callCount = 0
1361
1488
  const pathUsd = Challenge.from({
@@ -1544,8 +1671,42 @@ describe('Fetch.from: 402 retry path', () => {
1544
1671
  )
1545
1672
  })
1546
1673
 
1547
- test('retries exactly once does not loop on repeated 402', async () => {
1674
+ test('creates a fresh credential for each incremental 402 challenge', async () => {
1675
+ const createCredential = vi.fn(
1676
+ async ({ challenge }: { challenge: Challenge.Challenge }) => `credential-${challenge.id}`,
1677
+ )
1678
+ const calls: { init: RequestInit | undefined }[] = []
1679
+ let callCount = 0
1680
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
1681
+ calls.push({ init })
1682
+ callCount++
1683
+ if (callCount === 1) return make402({ id: 'first' })
1684
+ if (callCount === 2) return make402({ id: 'second' })
1685
+ if (callCount === 3) return make402({ id: 'third' })
1686
+ return new Response('OK', { status: 200 })
1687
+ }
1688
+
1689
+ const fetch = Fetch.from({
1690
+ fetch: mockFetch,
1691
+ methods: [{ ...noopMethod, createCredential }],
1692
+ })
1693
+
1694
+ const response = await fetch('https://example.com/api')
1695
+
1696
+ expect(response.status).toBe(200)
1697
+ expect(createCredential.mock.calls.map((call) => call[0].challenge.id)).toEqual([
1698
+ 'first',
1699
+ 'second',
1700
+ 'third',
1701
+ ])
1702
+ expect(
1703
+ calls.slice(1).map((call) => new Headers(call.init?.headers).get('Authorization')),
1704
+ ).toEqual(['credential-first', 'credential-second', 'credential-third'])
1705
+ })
1706
+
1707
+ test('caps repeated 402 retries at three', async () => {
1548
1708
  let callCount = 0
1709
+ const createCredential = vi.fn(async () => 'credential')
1549
1710
  const mockFetch: typeof globalThis.fetch = async () => {
1550
1711
  callCount++
1551
1712
  return make402()
@@ -1553,11 +1714,32 @@ describe('Fetch.from: 402 retry path', () => {
1553
1714
 
1554
1715
  const fetch = Fetch.from({
1555
1716
  fetch: mockFetch,
1556
- methods: [noopMethod],
1717
+ methods: [{ ...noopMethod, createCredential }],
1718
+ })
1719
+
1720
+ const response = await fetch('https://example.com/api')
1721
+ expect(callCount).toBe(4)
1722
+ expect(createCredential).toHaveBeenCalledTimes(3)
1723
+ expect(response.status).toBe(402)
1724
+ })
1725
+
1726
+ test('respects configured retry cap', async () => {
1727
+ let callCount = 0
1728
+ const createCredential = vi.fn(async () => 'credential')
1729
+ const mockFetch: typeof globalThis.fetch = async () => {
1730
+ callCount++
1731
+ return make402()
1732
+ }
1733
+
1734
+ const fetch = Fetch.from({
1735
+ fetch: mockFetch,
1736
+ maxPaymentRetries: 1,
1737
+ methods: [{ ...noopMethod, createCredential }],
1557
1738
  })
1558
1739
 
1559
1740
  const response = await fetch('https://example.com/api')
1560
1741
  expect(callCount).toBe(2)
1742
+ expect(createCredential).toHaveBeenCalledOnce()
1561
1743
  expect(response.status).toBe(402)
1562
1744
  })
1563
1745
  })