mppx 0.8.5 → 0.8.7

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 (134) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/Receipt.d.ts +20 -2
  3. package/dist/Receipt.d.ts.map +1 -1
  4. package/dist/Receipt.js +19 -12
  5. package/dist/Receipt.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +6 -4
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/internal.js +2 -2
  10. package/dist/cli/internal.js.map +1 -1
  11. package/dist/cli/plugins/evm.d.ts +2 -0
  12. package/dist/cli/plugins/evm.d.ts.map +1 -0
  13. package/dist/cli/plugins/evm.js +51 -0
  14. package/dist/cli/plugins/evm.js.map +1 -0
  15. package/dist/cli/plugins/index.d.ts +1 -0
  16. package/dist/cli/plugins/index.d.ts.map +1 -1
  17. package/dist/cli/plugins/index.js +1 -0
  18. package/dist/cli/plugins/index.js.map +1 -1
  19. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  20. package/dist/cli/plugins/tempo.js +4 -2
  21. package/dist/cli/plugins/tempo.js.map +1 -1
  22. package/dist/cli/validate/challenge.d.ts +2 -0
  23. package/dist/cli/validate/challenge.d.ts.map +1 -1
  24. package/dist/cli/validate/challenge.js +184 -78
  25. package/dist/cli/validate/challenge.js.map +1 -1
  26. package/dist/cli/validate/discovery.d.ts +1 -0
  27. package/dist/cli/validate/discovery.d.ts.map +1 -1
  28. package/dist/cli/validate/discovery.js +99 -35
  29. package/dist/cli/validate/discovery.js.map +1 -1
  30. package/dist/cli/validate/helpers.d.ts +14 -1
  31. package/dist/cli/validate/helpers.d.ts.map +1 -1
  32. package/dist/cli/validate/helpers.js +0 -13
  33. package/dist/cli/validate/helpers.js.map +1 -1
  34. package/dist/cli/validate/index.d.ts +1 -0
  35. package/dist/cli/validate/index.d.ts.map +1 -1
  36. package/dist/cli/validate/index.js +98 -151
  37. package/dist/cli/validate/index.js.map +1 -1
  38. package/dist/cli/validate/payment.d.ts +1 -0
  39. package/dist/cli/validate/payment.d.ts.map +1 -1
  40. package/dist/cli/validate/payment.js +215 -144
  41. package/dist/cli/validate/payment.js.map +1 -1
  42. package/dist/client/internal/Fetch.d.ts.map +1 -1
  43. package/dist/client/internal/Fetch.js +6 -1
  44. package/dist/client/internal/Fetch.js.map +1 -1
  45. package/dist/evm/Chains.d.ts +4 -0
  46. package/dist/evm/Chains.d.ts.map +1 -1
  47. package/dist/evm/Chains.js +4 -0
  48. package/dist/evm/Chains.js.map +1 -1
  49. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  50. package/dist/internal/AcceptPayment.js +7 -1
  51. package/dist/internal/AcceptPayment.js.map +1 -1
  52. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  53. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.js +1 -1
  55. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  56. package/dist/tempo/client/Charge.d.ts.map +1 -1
  57. package/dist/tempo/client/Charge.js +6 -2
  58. package/dist/tempo/client/Charge.js.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  60. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  61. package/dist/tempo/server/internal/html.gen.js +1 -1
  62. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  63. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  64. package/dist/tempo/server/internal/request-body.js +2 -0
  65. package/dist/tempo/server/internal/request-body.js.map +1 -1
  66. package/dist/tempo/session/precompile/Chain.d.ts +26 -2
  67. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  68. package/dist/tempo/session/precompile/Chain.js +38 -5
  69. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  70. package/dist/tempo/session/server/ChannelStore.js +4 -4
  71. package/dist/tempo/session/server/ChannelStore.js.map +1 -1
  72. package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
  73. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  74. package/dist/tempo/session/server/CredentialVerification.js +21 -1
  75. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  76. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  77. package/dist/tempo/session/server/Session.js +1 -0
  78. package/dist/tempo/session/server/Session.js.map +1 -1
  79. package/dist/validation/core.d.ts +79 -0
  80. package/dist/validation/core.d.ts.map +1 -0
  81. package/dist/validation/core.js +256 -0
  82. package/dist/validation/core.js.map +1 -0
  83. package/dist/validation/index.d.ts +3 -0
  84. package/dist/validation/index.d.ts.map +1 -0
  85. package/dist/validation/index.js +2 -0
  86. package/dist/validation/index.js.map +1 -0
  87. package/dist/x402/Assets.d.ts +9 -0
  88. package/dist/x402/Assets.d.ts.map +1 -1
  89. package/dist/x402/Assets.js +38 -0
  90. package/dist/x402/Assets.js.map +1 -1
  91. package/package.json +9 -4
  92. package/src/Receipt.test.ts +53 -0
  93. package/src/Receipt.ts +26 -13
  94. package/src/cli/cli.test.ts +41 -5
  95. package/src/cli/cli.ts +7 -4
  96. package/src/cli/internal.ts +2 -2
  97. package/src/cli/plugins/evm.ts +55 -0
  98. package/src/cli/plugins/index.ts +1 -0
  99. package/src/cli/plugins/tempo.ts +6 -2
  100. package/src/cli/validate/challenge.ts +334 -121
  101. package/src/cli/validate/discovery.test.ts +307 -0
  102. package/src/cli/validate/discovery.ts +104 -38
  103. package/src/cli/validate/helpers.ts +15 -13
  104. package/src/cli/validate/index.ts +117 -240
  105. package/src/cli/validate/payment.ts +261 -178
  106. package/src/cli/validate.test.ts +143 -3
  107. package/src/client/internal/Fetch.test.ts +186 -2
  108. package/src/client/internal/Fetch.ts +5 -1
  109. package/src/evm/Chains.ts +6 -0
  110. package/src/evm/PublicInterface.test-d.ts +7 -0
  111. package/src/internal/AcceptPayment.test.ts +45 -0
  112. package/src/internal/AcceptPayment.ts +11 -1
  113. package/src/stripe/server/internal/html/package.json +1 -1
  114. package/src/stripe/server/internal/html.gen.ts +1 -1
  115. package/src/tempo/client/Charge.test.ts +61 -0
  116. package/src/tempo/client/Charge.ts +6 -1
  117. package/src/tempo/server/internal/html/package.json +1 -1
  118. package/src/tempo/server/internal/html.gen.ts +1 -1
  119. package/src/tempo/server/internal/request-body.test.ts +9 -0
  120. package/src/tempo/server/internal/request-body.ts +2 -0
  121. package/src/tempo/session/precompile/Chain.test.ts +121 -2
  122. package/src/tempo/session/precompile/Chain.ts +56 -6
  123. package/src/tempo/session/server/ChannelStore.test.ts +17 -0
  124. package/src/tempo/session/server/ChannelStore.ts +4 -4
  125. package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
  126. package/src/tempo/session/server/CredentialVerification.ts +30 -0
  127. package/src/tempo/session/server/Session.integration.test.ts +5 -0
  128. package/src/tempo/session/server/Session.test.ts +119 -53
  129. package/src/tempo/session/server/Session.ts +1 -0
  130. package/src/validation/core.ts +348 -0
  131. package/src/validation/index.ts +11 -0
  132. package/src/x402/Assets.test.ts +42 -0
  133. package/src/x402/Assets.ts +40 -0
  134. package/src/x402/Exact.localnet.test.ts +475 -0
@@ -1,5 +1,7 @@
1
1
  import * as node_http from 'node:http'
2
2
 
3
+ import { serve } from '@hono/node-server'
4
+ import { Hono } from 'hono'
3
5
  import { Challenge, Constants, Credential } from 'mppx'
4
6
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
5
7
  import {
@@ -235,6 +237,18 @@ function verifyRequestWithFeePayer(channelId: Hex, feePayer: typeof payer): Veri
235
237
  } as unknown as VerifyRequest
236
238
  }
237
239
 
240
+ function sourceFor(account = payer): string {
241
+ return `did:pkh:eip155:${chainId}:${account.address}`
242
+ }
243
+
244
+ function voucherCredential(payload: SessionCredentialPayload, channelId: Hex) {
245
+ return {
246
+ challenge: makeChallenge(channelId),
247
+ payload,
248
+ source: sourceFor(),
249
+ }
250
+ }
251
+
238
252
  let saltCounter = 0
239
253
 
240
254
  async function createOpenPayload(
@@ -1434,10 +1448,7 @@ describe('precompile server session unit guardrails', () => {
1434
1448
  )
1435
1449
 
1436
1450
  const receipt = (await method.verify({
1437
- credential: {
1438
- challenge: makeChallenge(openPayload.channelId),
1439
- payload: voucher,
1440
- },
1451
+ credential: voucherCredential(voucher, openPayload.channelId),
1441
1452
  request: verifyRequest(openPayload.channelId),
1442
1453
  })) as SessionReceipt
1443
1454
 
@@ -1473,10 +1484,7 @@ describe('precompile server session unit guardrails', () => {
1473
1484
  })
1474
1485
 
1475
1486
  const receipt = (await method.verify({
1476
- credential: {
1477
- challenge: makeChallenge(openPayload.channelId),
1478
- payload: voucher,
1479
- },
1487
+ credential: voucherCredential(voucher, openPayload.channelId),
1480
1488
  request: verifyRequest(openPayload.channelId),
1481
1489
  })) as SessionReceipt
1482
1490
 
@@ -1506,10 +1514,7 @@ describe('precompile server session unit guardrails', () => {
1506
1514
 
1507
1515
  await expect(
1508
1516
  method.verify({
1509
- credential: {
1510
- challenge: makeChallenge(openPayload.channelId),
1511
- payload: voucher,
1512
- },
1517
+ credential: voucherCredential(voucher, openPayload.channelId),
1513
1518
  request: verifyRequest(openPayload.channelId),
1514
1519
  }),
1515
1520
  ).rejects.toThrow(
@@ -1534,16 +1539,13 @@ describe('precompile server session unit guardrails', () => {
1534
1539
 
1535
1540
  await expect(
1536
1541
  method.verify({
1537
- credential: {
1538
- challenge: makeChallenge(openPayload.channelId),
1539
- payload: voucher,
1540
- },
1542
+ credential: voucherCredential(voucher, openPayload.channelId),
1541
1543
  request: verifyRequest(openPayload.channelId),
1542
1544
  }),
1543
1545
  ).rejects.toThrow(/voucher delta 150 below minimum 200/)
1544
1546
  })
1545
1547
 
1546
- test('accepts idempotent precompile voucher replay after on-chain settlement catches up', async () => {
1548
+ test('rejects idempotent precompile voucher replay after on-chain settlement catches up', async () => {
1547
1549
  const rawStore = Store.memory()
1548
1550
  const store = channelStore(rawStore)
1549
1551
  const openPayload = await createOpenPayload({ initialAmount: 100n })
@@ -1572,17 +1574,12 @@ describe('precompile server session unit guardrails', () => {
1572
1574
  createStateClient(payer, { settled: 500n, deposit: 1_000n, closeRequestedAt: 0 }),
1573
1575
  })
1574
1576
 
1575
- const receipt = (await method.verify({
1576
- credential: {
1577
- challenge: makeChallenge(openPayload.channelId),
1578
- payload: voucher,
1579
- },
1580
- request: verifyRequest(openPayload.channelId),
1581
- })) as SessionReceipt
1582
-
1583
- expect(receipt.acceptedCumulative).toBe('500')
1584
- expect(receipt.spent).toBe('500')
1585
- expect(receipt.units).toBe(10)
1577
+ await expect(
1578
+ method.verify({
1579
+ credential: voucherCredential(voucher, openPayload.channelId),
1580
+ request: verifyRequest(openPayload.channelId),
1581
+ }),
1582
+ ).rejects.toThrow(/below on-chain settled amount/)
1586
1583
  })
1587
1584
 
1588
1585
  test('rejects stale or hijacked precompile voucher signatures', async () => {
@@ -1599,16 +1596,16 @@ describe('precompile server session unit guardrails', () => {
1599
1596
 
1600
1597
  await expect(
1601
1598
  method.verify({
1602
- credential: {
1603
- challenge: makeChallenge(openPayload.channelId),
1604
- payload: {
1599
+ credential: voucherCredential(
1600
+ {
1605
1601
  action: 'voucher',
1606
1602
  channelId: openPayload.channelId,
1607
1603
  cumulativeAmount: '250',
1608
1604
  descriptor: openPayload.descriptor,
1609
1605
  signature,
1610
1606
  },
1611
- },
1607
+ openPayload.channelId,
1608
+ ),
1612
1609
  request: verifyRequest(openPayload.channelId),
1613
1610
  }),
1614
1611
  ).rejects.toThrow(/invalid voucher signature/)
@@ -1628,10 +1625,7 @@ describe('precompile server session unit guardrails', () => {
1628
1625
 
1629
1626
  await expect(
1630
1627
  method.verify({
1631
- credential: {
1632
- challenge: makeChallenge(openPayload.channelId),
1633
- payload: voucher,
1634
- },
1628
+ credential: voucherCredential(voucher, openPayload.channelId),
1635
1629
  request: verifyRequest(openPayload.channelId),
1636
1630
  }),
1637
1631
  ).rejects.toThrow(/exceeds.*deposit|insufficient channel deposit/)
@@ -1664,10 +1658,7 @@ describe('precompile server session unit guardrails', () => {
1664
1658
 
1665
1659
  await expect(
1666
1660
  method.verify({
1667
- credential: {
1668
- challenge: makeChallenge(openPayload.channelId),
1669
- payload: voucher,
1670
- },
1661
+ credential: voucherCredential(voucher, openPayload.channelId),
1671
1662
  request: verifyRequest(openPayload.channelId),
1672
1663
  }),
1673
1664
  ).rejects.toThrow(/pending close request/)
@@ -1699,10 +1690,7 @@ describe('precompile server session unit guardrails', () => {
1699
1690
 
1700
1691
  await expect(
1701
1692
  method.verify({
1702
- credential: {
1703
- challenge: makeChallenge(openPayload.channelId),
1704
- payload: voucher,
1705
- },
1693
+ credential: voucherCredential(voucher, openPayload.channelId),
1706
1694
  request: verifyRequest(openPayload.channelId),
1707
1695
  }),
1708
1696
  ).rejects.toThrow(/deposit is zero|channel deposit is zero|not found/)
@@ -1853,6 +1841,7 @@ describe('precompile server session unit guardrails', () => {
1853
1841
  Authorization: Credential.serialize({
1854
1842
  challenge: Challenge.fromResponse(first.challenge),
1855
1843
  payload: voucher,
1844
+ source: sourceFor(),
1856
1845
  }),
1857
1846
  },
1858
1847
  }),
@@ -1874,6 +1863,7 @@ describe('precompile server session unit guardrails', () => {
1874
1863
  Authorization: Credential.serialize({
1875
1864
  challenge: Challenge.fromResponse(replayChallenge.challenge),
1876
1865
  payload: voucher,
1866
+ source: sourceFor(),
1877
1867
  }),
1878
1868
  },
1879
1869
  }),
@@ -1915,6 +1905,7 @@ describe('precompile server session unit guardrails', () => {
1915
1905
  Credential.serialize({
1916
1906
  challenge: Challenge.fromResponse(first.challenge),
1917
1907
  payload: voucher,
1908
+ source: sourceFor(),
1918
1909
  }),
1919
1910
  ),
1920
1911
  )
@@ -1933,6 +1924,7 @@ describe('precompile server session unit guardrails', () => {
1933
1924
  Credential.serialize({
1934
1925
  challenge: Challenge.fromResponse(replayChallenge.challenge),
1935
1926
  payload: voucher,
1927
+ source: sourceFor(),
1936
1928
  }),
1937
1929
  ),
1938
1930
  )
@@ -1963,6 +1955,7 @@ describe('precompile server session unit guardrails', () => {
1963
1955
  descriptor: openPayload.descriptor,
1964
1956
  signature: (await createOpenPayload({ account: wrongPayer })).signature,
1965
1957
  },
1958
+ source: sourceFor(),
1966
1959
  }),
1967
1960
  },
1968
1961
  }),
@@ -2125,6 +2118,7 @@ describe('precompile server session unit guardrails', () => {
2125
2118
  let voucherPosts = 0
2126
2119
  const amount = options.amount ?? '1'
2127
2120
  const maxDeposit = options.maxDeposit ?? 3n
2121
+ let deposit = maxDeposit
2128
2122
  const unitType = options.unitType ?? 'token'
2129
2123
  const route = Mppx_server.create({
2130
2124
  methods: [
@@ -2151,12 +2145,19 @@ describe('precompile server session unit guardrails', () => {
2151
2145
  receipt: transactionReceipt([
2152
2146
  closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n),
2153
2147
  ]),
2154
- state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2148
+ state: { settled: 0n, deposit, closeRequestedAt: 0 },
2149
+ })
2150
+ }
2151
+ if (payload?.action === 'topUp') {
2152
+ deposit += BigInt(payload.additionalDeposit)
2153
+ return createServerClient([], payer, payload.channelId, {
2154
+ receipt: transactionReceipt([topUpLog(payload, deposit)]),
2155
+ state: { settled: 0n, deposit, closeRequestedAt: 0 },
2155
2156
  })
2156
2157
  }
2157
2158
  return createStateClient(payer, {
2158
2159
  settled: 0n,
2159
- deposit: maxDeposit,
2160
+ deposit,
2160
2161
  closeRequestedAt: 0,
2161
2162
  })
2162
2163
  },
@@ -2166,8 +2167,7 @@ describe('precompile server session unit guardrails', () => {
2166
2167
  secretKey: 'test-secret-key-test-secret-key-32',
2167
2168
  }).session({ amount, decimals: 0, suggestedDeposit: maxDeposit.toString(), unitType })
2168
2169
 
2169
- const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
2170
- const request = new Request(input, init)
2170
+ const handle = async (request: Request) => {
2171
2171
  currentPayload = undefined
2172
2172
  if (request.headers.has('Authorization')) {
2173
2173
  try {
@@ -2178,7 +2178,7 @@ describe('precompile server session unit guardrails', () => {
2178
2178
 
2179
2179
  const result = await route(request)
2180
2180
  if (result.status === 402) return result.challenge
2181
- if (currentPayload?.action === 'voucher') return new Response(null, { status: 200 })
2181
+ if (request.method === 'POST') return result.withReceipt()
2182
2182
 
2183
2183
  if (request.headers.get('Accept')?.includes('text/event-stream')) {
2184
2184
  if (unitType === 'request') {
@@ -2211,8 +2211,12 @@ describe('precompile server session unit guardrails', () => {
2211
2211
  return result.withReceipt(new Response('ok'))
2212
2212
  }
2213
2213
 
2214
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) =>
2215
+ handle(new Request(input, init))
2216
+
2214
2217
  return {
2215
2218
  fetch,
2219
+ handle,
2216
2220
  rawStore,
2217
2221
  get voucherPosts() {
2218
2222
  return voucherPosts
@@ -2247,6 +2251,71 @@ describe('precompile server session unit guardrails', () => {
2247
2251
  expect(persisted?.finalized).toBe(true)
2248
2252
  })
2249
2253
 
2254
+ test('handles empty management POST streams through a Node adapter', async () => {
2255
+ const harness = createManagedSseFetch({ maxDeposit: 1n })
2256
+ const managementPosts: Array<{
2257
+ action: SessionCredentialPayload['action']
2258
+ contentLength: string | null
2259
+ hasBody: boolean
2260
+ status: number
2261
+ }> = []
2262
+ const app = new Hono()
2263
+ app.all('*', async (c) => {
2264
+ const request = c.req.raw
2265
+ const payload = request.headers.has('Authorization')
2266
+ ? Credential.fromRequest<SessionCredentialPayload>(request).payload
2267
+ : undefined
2268
+ const response = await harness.handle(request)
2269
+ if (request.method === 'POST' && payload)
2270
+ managementPosts.push({
2271
+ action: payload.action,
2272
+ contentLength: request.headers.get('content-length'),
2273
+ hasBody: request.body !== null,
2274
+ status: response.status,
2275
+ })
2276
+ return response
2277
+ })
2278
+ const server = serve({ fetch: app.fetch, port: 0 })
2279
+ await new Promise<void>((resolve) => server.once('listening', resolve))
2280
+ const address = server.address()
2281
+ if (!address || typeof address === 'string') throw new Error('expected TCP address')
2282
+
2283
+ try {
2284
+ const fetch: typeof globalThis.fetch = async (input, init) => {
2285
+ const response = await globalThis.fetch(input, init)
2286
+ return new Response(response.body, response)
2287
+ }
2288
+ const manager = precompileSessionManager({
2289
+ account: payer,
2290
+ client: createSigningClient(),
2291
+ decimals: 0,
2292
+ fetch,
2293
+ maxDeposit: '3',
2294
+ })
2295
+
2296
+ const chunks: string[] = []
2297
+ const stream = await manager.sse(`http://localhost:${address.port}/stream`)
2298
+ for await (const chunk of stream) chunks.push(chunk)
2299
+
2300
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
2301
+ const closeReceipt = await manager.close()
2302
+ expect(closeReceipt?.spent).toBe('3')
2303
+ expect(managementPosts).toEqual(
2304
+ expect.arrayContaining([
2305
+ { action: 'topUp', contentLength: '0', hasBody: true, status: 204 },
2306
+ { action: 'voucher', contentLength: '0', hasBody: true, status: 204 },
2307
+ { action: 'close', contentLength: '0', hasBody: true, status: 204 },
2308
+ ]),
2309
+ )
2310
+ expect(managementPosts.every(({ status }) => status === 204)).toBe(true)
2311
+
2312
+ const persisted = await channelStore(harness.rawStore).getChannel(manager.channelId!)
2313
+ expect(persisted?.finalized).toBe(true)
2314
+ } finally {
2315
+ server.close()
2316
+ }
2317
+ })
2318
+
2250
2319
  test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
2251
2320
  const harness = createManagedSseFetch({ maxDeposit: 1n, unitType: 'request' })
2252
2321
  const manager = precompileSessionManager({
@@ -2848,10 +2917,7 @@ describe('precompile server session unit guardrails', () => {
2848
2917
  })
2849
2918
 
2850
2919
  const receipt = await method.verify({
2851
- credential: {
2852
- challenge: makeChallenge(openPayload.channelId),
2853
- payload: lowerVoucher,
2854
- },
2920
+ credential: voucherCredential(lowerVoucher, openPayload.channelId),
2855
2921
  request: verifyRequest(openPayload.channelId),
2856
2922
  })
2857
2923
 
@@ -413,6 +413,7 @@ export function session<const parameters extends session.Parameters>(
413
413
  channelStateTtl,
414
414
  chainId: context.chainId,
415
415
  client: context.client,
416
+ credentialSource: credential.source,
416
417
  escrow: context.escrow,
417
418
  expectedOperator: context.methodDetails.operator,
418
419
  feePayer: context.feePayer,
@@ -0,0 +1,348 @@
1
+ import { validateChallenge, validateErrorHandling } from '../cli/validate/challenge.js'
2
+ import {
3
+ buildUrl,
4
+ extractEndpointsFromDiscovery,
5
+ extractRequestBodyFromDiscovery,
6
+ fetchDiscoveryDoc,
7
+ } from '../cli/validate/discovery.js'
8
+ import type { CheckResult, EndpointSpec, PathParameter } from '../cli/validate/helpers.js'
9
+ import {
10
+ isValidIntegerAmount,
11
+ parseEndpointArg,
12
+ resolveBodyForEndpoint,
13
+ } from '../cli/validate/helpers.js'
14
+ import { validatePaymentFlow } from '../cli/validate/payment.js'
15
+ import { validate as validateDiscoveryDoc } from '../discovery/Validate.js'
16
+
17
+ export { buildUrl }
18
+ export type { CheckResult, EndpointSpec, PathParameter }
19
+
20
+ export type ValidateOptions = {
21
+ url: string
22
+ endpoint?: string | undefined
23
+ body?: string | undefined
24
+ query?: string[] | undefined
25
+ verbose?: boolean | undefined
26
+ yes?: boolean | undefined
27
+ skipPayment?: boolean | undefined
28
+ discoveryPath?: string | undefined
29
+ onPaymentResults?: (results: CheckResult[]) => void
30
+ }
31
+
32
+ export type DiscoveryResult = {
33
+ found: boolean
34
+ valid: boolean
35
+ endpoints: EndpointSpec[]
36
+ checks: CheckResult[]
37
+ doc: Record<string, unknown> | null
38
+ }
39
+
40
+ export type EndpointValidationResult = {
41
+ method: string
42
+ path: string
43
+ challenge: CheckResult[]
44
+ errorHandling: CheckResult[]
45
+ payment: CheckResult[]
46
+ }
47
+
48
+ export type ValidateResult = {
49
+ url: string
50
+ discovery: DiscoveryResult
51
+ endpoints: EndpointValidationResult[]
52
+ summary: { passed: number; failed: number; warnings: number; skipped: number }
53
+ flags: {
54
+ sawMppEndpoint: boolean
55
+ sawNonMppPaymentEndpoint: boolean
56
+ sawMalformedChallenge: boolean
57
+ sawTestnet: boolean
58
+ sawMainnet: boolean
59
+ paymentSucceeded: boolean
60
+ }
61
+ }
62
+
63
+ export type ValidateEvent =
64
+ | { phase: 'discovery'; discovery: DiscoveryResult; results: CheckResult[] }
65
+ | { phase: 'endpoint'; endpoint: EndpointSpec }
66
+ | {
67
+ phase: 'challenge'
68
+ endpoint: EndpointSpec
69
+ results: CheckResult[]
70
+ isMpp: boolean
71
+ isTestnet: boolean
72
+ isNonMppPayment: boolean
73
+ isMalformedChallenge: boolean
74
+ }
75
+ | { phase: 'errorHandling'; endpoint: EndpointSpec; results: CheckResult[] }
76
+ | {
77
+ phase: 'payment'
78
+ endpoint: EndpointSpec
79
+ results: CheckResult[]
80
+ succeeded: boolean
81
+ body?: string | undefined
82
+ }
83
+
84
+ /** Streams validation results as each phase completes. */
85
+ export async function* validateStream(options: ValidateOptions): AsyncGenerator<ValidateEvent> {
86
+ const baseUrl = options.url.replace(/\/$/, '').replace(/\/openapi\.json$/i, '')
87
+ const verbose = options.verbose ?? false
88
+
89
+ const discovery = await runDiscovery(baseUrl, options)
90
+ yield { phase: 'discovery', discovery, results: discovery.checks }
91
+
92
+ for (const endpoint of discovery.endpoints) {
93
+ yield { phase: 'endpoint', endpoint }
94
+
95
+ let body: string | undefined
96
+ if (options.endpoint) {
97
+ body = options.body
98
+ } else {
99
+ body = resolveBodyForEndpoint(options.body, endpoint.path)
100
+ if (!body && discovery.doc) {
101
+ body = extractRequestBodyFromDiscovery(discovery.doc, endpoint)
102
+ }
103
+ }
104
+
105
+ const { results: challengeResults, resolvedBody } = await validateChallenge(
106
+ baseUrl,
107
+ endpoint,
108
+ verbose,
109
+ {
110
+ body,
111
+ query: options.query,
112
+ discoveryDoc: discovery.doc ?? undefined,
113
+ },
114
+ )
115
+ const effectiveBody = resolvedBody ?? body
116
+ const isMpp = challengeResults.some(
117
+ (r) => r.severity === 'pass' && r.label === 'Challenge parseable',
118
+ )
119
+ const isTestnet = challengeResults.some(
120
+ (r) =>
121
+ r.severity === 'pass' &&
122
+ r.label.endsWith('Valid currency address') &&
123
+ r.detail === 'testnet',
124
+ )
125
+ const isNonMppPayment =
126
+ !isMpp && challengeResults.some((r) => r.label === 'Not an MPP endpoint')
127
+ const hasPaymentScheme = challengeResults.some(
128
+ (r) => r.severity === 'pass' && r.label === 'WWW-Authenticate header present',
129
+ )
130
+ const challengeFailed = challengeResults.some(
131
+ (r) => r.severity === 'fail' && r.label === 'Challenge parseable',
132
+ )
133
+ const isMalformedChallenge = !isMpp && hasPaymentScheme && challengeFailed
134
+
135
+ yield {
136
+ phase: 'challenge',
137
+ endpoint,
138
+ results: challengeResults,
139
+ isMpp,
140
+ isTestnet,
141
+ isNonMppPayment,
142
+ isMalformedChallenge,
143
+ }
144
+
145
+ if (isMpp) {
146
+ const errorResults = await validateErrorHandling(baseUrl, endpoint, {
147
+ body: effectiveBody,
148
+ query: options.query,
149
+ })
150
+ yield { phase: 'errorHandling', endpoint, results: errorResults }
151
+
152
+ if (!options.skipPayment) {
153
+ const onResults = options.onPaymentResults
154
+ const paymentResults = await validatePaymentFlow(baseUrl, endpoint, verbose, {
155
+ body: effectiveBody,
156
+ query: options.query,
157
+ yes: options.yes,
158
+ ...(onResults && { onResults }),
159
+ })
160
+ const succeeded = paymentResults.some(
161
+ (r) => r.severity === 'pass' && r.label === 'Payment: successful',
162
+ )
163
+ yield { phase: 'payment', endpoint, results: paymentResults, succeeded }
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Runs the full validation suite and returns all results as a batch. */
170
+ export async function validate(options: ValidateOptions): Promise<ValidateResult> {
171
+ const baseUrl = options.url.replace(/\/$/, '').replace(/\/openapi\.json$/i, '')
172
+ let discovery: DiscoveryResult | undefined
173
+ const endpointResults: EndpointValidationResult[] = []
174
+ let current: EndpointValidationResult | undefined
175
+
176
+ let sawTestnet = false
177
+ let sawMainnet = false
178
+ let paymentSucceeded = false
179
+ let sawMppEndpoint = false
180
+ let sawNonMppPaymentEndpoint = false
181
+ let sawMalformedChallenge = false
182
+
183
+ for await (const event of validateStream(options)) {
184
+ switch (event.phase) {
185
+ case 'discovery':
186
+ discovery = event.discovery
187
+ break
188
+ case 'endpoint':
189
+ current = {
190
+ method: event.endpoint.method,
191
+ path: event.endpoint.path,
192
+ challenge: [],
193
+ errorHandling: [],
194
+ payment: [],
195
+ }
196
+ endpointResults.push(current)
197
+ break
198
+ case 'challenge':
199
+ if (current) current.challenge = event.results
200
+ if (event.isMpp) {
201
+ sawMppEndpoint = true
202
+ if (event.isTestnet) sawTestnet = true
203
+ else sawMainnet = true
204
+ }
205
+ if (event.isNonMppPayment) sawNonMppPaymentEndpoint = true
206
+ if (event.isMalformedChallenge) sawMalformedChallenge = true
207
+ break
208
+ case 'errorHandling':
209
+ if (current) current.errorHandling = event.results
210
+ break
211
+ case 'payment':
212
+ if (current) current.payment = event.results
213
+ if (event.succeeded) paymentSucceeded = true
214
+ break
215
+ }
216
+ }
217
+
218
+ const summary = { passed: 0, failed: 0, warnings: 0, skipped: 0 }
219
+ if (discovery) {
220
+ for (const r of discovery.checks) {
221
+ if (r.severity === 'pass') summary.passed++
222
+ else if (r.severity === 'fail') summary.failed++
223
+ else if (r.severity === 'warn') summary.warnings++
224
+ else if (r.severity === 'skip') summary.skipped++
225
+ }
226
+ }
227
+ for (const ep of endpointResults) {
228
+ for (const r of [...ep.challenge, ...ep.errorHandling, ...ep.payment]) {
229
+ if (r.severity === 'pass') summary.passed++
230
+ else if (r.severity === 'fail') summary.failed++
231
+ else if (r.severity === 'warn') summary.warnings++
232
+ else if (r.severity === 'skip') summary.skipped++
233
+ }
234
+ }
235
+
236
+ if (!discovery) throw new Error('Discovery phase did not complete')
237
+
238
+ return {
239
+ url: baseUrl,
240
+ discovery,
241
+ endpoints: endpointResults,
242
+ summary,
243
+ flags: {
244
+ sawMppEndpoint,
245
+ sawNonMppPaymentEndpoint,
246
+ sawMalformedChallenge,
247
+ sawTestnet,
248
+ sawMainnet,
249
+ paymentSucceeded,
250
+ },
251
+ }
252
+ }
253
+
254
+ async function runDiscovery(baseUrl: string, options: ValidateOptions): Promise<DiscoveryResult> {
255
+ const checks: CheckResult[] = []
256
+ let endpoints: EndpointSpec[] = []
257
+ let discoveryDoc: Record<string, unknown> | null = null
258
+ let found = false
259
+ let valid = false
260
+
261
+ // Try the user's path first, then root, then /api.
262
+ const origin = new URL(baseUrl).origin
263
+ let candidates: string[]
264
+ if (options.discoveryPath) {
265
+ candidates = [
266
+ new URL(options.discoveryPath, baseUrl + '/').href.replace(/\/openapi\.json$/i, ''),
267
+ ]
268
+ } else {
269
+ candidates = [baseUrl, origin, `${origin}/api`]
270
+ candidates = [...new Set(candidates)]
271
+ }
272
+ let discoveryResult: Awaited<ReturnType<typeof fetchDiscoveryDoc>> | undefined
273
+ const attemptedErrors: string[] = []
274
+ for (const candidate of candidates) {
275
+ discoveryResult = await fetchDiscoveryDoc(candidate)
276
+ if ('error' in discoveryResult) {
277
+ attemptedErrors.push(`${candidate}/openapi.json: ${discoveryResult.error}`)
278
+ } else {
279
+ break
280
+ }
281
+ }
282
+
283
+ if (!discoveryResult || 'error' in discoveryResult) {
284
+ checks.push({
285
+ label: 'Document found',
286
+ detail: attemptedErrors.join('; '),
287
+ hint: 'MPP servers must serve an OpenAPI document at /openapi.json with x-payment-info extensions.',
288
+ severity: 'fail',
289
+ })
290
+ } else {
291
+ found = true
292
+ checks.push({ label: 'Document found and parseable', severity: 'pass' })
293
+
294
+ const issues = validateDiscoveryDoc(discoveryResult.doc)
295
+ const errors = issues.filter((i) => i.severity === 'error')
296
+ const warnings = issues.filter((i) => i.severity === 'warning')
297
+
298
+ if (errors.length > 0) {
299
+ checks.push({
300
+ label: 'Valid OpenAPI structure',
301
+ detail: `${errors.length} error(s)`,
302
+ severity: 'fail',
303
+ })
304
+ for (const e of errors) {
305
+ checks.push({ label: e.message, detail: e.path, severity: 'fail' })
306
+ }
307
+ } else {
308
+ valid = true
309
+ checks.push({ label: 'Valid OpenAPI structure', severity: 'pass' })
310
+ }
311
+
312
+ for (const w of warnings) {
313
+ checks.push({ label: w.message, detail: w.path, severity: 'warn' })
314
+ }
315
+
316
+ discoveryDoc = discoveryResult.doc as Record<string, unknown>
317
+ }
318
+
319
+ if (options.endpoint) {
320
+ const parsed = parseEndpointArg(options.endpoint)
321
+ if (parsed) endpoints.push(parsed)
322
+ } else if (discoveryDoc) {
323
+ endpoints = extractEndpointsFromDiscovery(discoveryDoc)
324
+
325
+ const NO_AMOUNT = BigInt('999999999999999999')
326
+ endpoints.sort((a, b) => {
327
+ const aAmt = isValidIntegerAmount(a.amount) ? BigInt(a.amount!) : NO_AMOUNT
328
+ const bAmt = isValidIntegerAmount(b.amount) ? BigInt(b.amount!) : NO_AMOUNT
329
+ return aAmt < bAmt ? -1 : aAmt > bAmt ? 1 : 0
330
+ })
331
+
332
+ if (endpoints.length > 0) {
333
+ checks.push({
334
+ label: 'Paid endpoints found',
335
+ detail: `${endpoints.length} endpoint(s)`,
336
+ severity: 'pass',
337
+ })
338
+ } else {
339
+ checks.push({
340
+ label: 'Paid endpoints found',
341
+ detail: 'No endpoints with x-payment-info',
342
+ severity: 'warn',
343
+ })
344
+ }
345
+ }
346
+
347
+ return { found, valid, endpoints, checks, doc: discoveryDoc }
348
+ }