mppx 0.6.28 → 0.6.29

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 (272) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +16 -10
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/Method.d.ts +1 -1
  6. package/dist/Method.d.ts.map +1 -1
  7. package/dist/client/Methods.d.ts +1 -0
  8. package/dist/client/Methods.d.ts.map +1 -1
  9. package/dist/client/Methods.js +1 -0
  10. package/dist/client/Methods.js.map +1 -1
  11. package/dist/client/Mppx.d.ts +3 -3
  12. package/dist/client/Mppx.d.ts.map +1 -1
  13. package/dist/client/Mppx.js +1 -0
  14. package/dist/client/Mppx.js.map +1 -1
  15. package/dist/client/Transport.d.ts +10 -3
  16. package/dist/client/Transport.d.ts.map +1 -1
  17. package/dist/client/Transport.js +60 -7
  18. package/dist/client/Transport.js.map +1 -1
  19. package/dist/client/index.d.ts +1 -1
  20. package/dist/client/index.d.ts.map +1 -1
  21. package/dist/client/index.js +1 -1
  22. package/dist/client/index.js.map +1 -1
  23. package/dist/client/internal/Fetch.d.ts +3 -0
  24. package/dist/client/internal/Fetch.d.ts.map +1 -1
  25. package/dist/client/internal/Fetch.js +12 -20
  26. package/dist/client/internal/Fetch.js.map +1 -1
  27. package/dist/evm/Assets.d.ts +2 -0
  28. package/dist/evm/Assets.d.ts.map +1 -0
  29. package/dist/evm/Assets.js +2 -0
  30. package/dist/evm/Assets.js.map +1 -0
  31. package/dist/evm/Chains.d.ts +5 -0
  32. package/dist/evm/Chains.d.ts.map +1 -0
  33. package/dist/evm/Chains.js +5 -0
  34. package/dist/evm/Chains.js.map +1 -0
  35. package/dist/evm/Methods.d.ts +68 -0
  36. package/dist/evm/Methods.d.ts.map +1 -0
  37. package/dist/evm/Methods.js +28 -0
  38. package/dist/evm/Methods.js.map +1 -0
  39. package/dist/evm/Types.d.ts +143 -0
  40. package/dist/evm/Types.d.ts.map +1 -0
  41. package/dist/evm/Types.js +102 -0
  42. package/dist/evm/Types.js.map +1 -0
  43. package/dist/evm/client/Charge.d.ts +102 -0
  44. package/dist/evm/client/Charge.d.ts.map +1 -0
  45. package/dist/evm/client/Charge.js +141 -0
  46. package/dist/evm/client/Charge.js.map +1 -0
  47. package/dist/evm/client/Methods.d.ts +81 -0
  48. package/dist/evm/client/Methods.d.ts.map +1 -0
  49. package/dist/evm/client/Methods.js +16 -0
  50. package/dist/evm/client/Methods.js.map +1 -0
  51. package/dist/evm/client/index.d.ts +6 -0
  52. package/dist/evm/client/index.d.ts.map +1 -0
  53. package/dist/evm/client/index.js +6 -0
  54. package/dist/evm/client/index.js.map +1 -0
  55. package/dist/evm/index.d.ts +9 -0
  56. package/dist/evm/index.d.ts.map +1 -0
  57. package/dist/evm/index.js +8 -0
  58. package/dist/evm/index.js.map +1 -0
  59. package/dist/evm/server/Charge.d.ts +62 -0
  60. package/dist/evm/server/Charge.d.ts.map +1 -0
  61. package/dist/evm/server/Charge.js +172 -0
  62. package/dist/evm/server/Charge.js.map +1 -0
  63. package/dist/evm/server/Methods.d.ts +80 -0
  64. package/dist/evm/server/Methods.d.ts.map +1 -0
  65. package/dist/evm/server/Methods.js +16 -0
  66. package/dist/evm/server/Methods.js.map +1 -0
  67. package/dist/evm/server/index.d.ts +6 -0
  68. package/dist/evm/server/index.d.ts.map +1 -0
  69. package/dist/evm/server/index.js +6 -0
  70. package/dist/evm/server/index.js.map +1 -0
  71. package/dist/index.d.ts +2 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +2 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/internal/HeaderCodec.d.ts +18 -0
  76. package/dist/internal/HeaderCodec.d.ts.map +1 -0
  77. package/dist/internal/HeaderCodec.js +31 -0
  78. package/dist/internal/HeaderCodec.js.map +1 -0
  79. package/dist/middlewares/elysia.d.ts.map +1 -1
  80. package/dist/middlewares/elysia.js +2 -3
  81. package/dist/middlewares/elysia.js.map +1 -1
  82. package/dist/middlewares/express.js +2 -1
  83. package/dist/middlewares/express.js.map +1 -1
  84. package/dist/proxy/internal/Headers.d.ts.map +1 -1
  85. package/dist/proxy/internal/Headers.js +11 -1
  86. package/dist/proxy/internal/Headers.js.map +1 -1
  87. package/dist/proxy/services/openai.d.ts.map +1 -1
  88. package/dist/proxy/services/openai.js +2 -0
  89. package/dist/proxy/services/openai.js.map +1 -1
  90. package/dist/server/Methods.d.ts +1 -0
  91. package/dist/server/Methods.d.ts.map +1 -1
  92. package/dist/server/Methods.js +1 -0
  93. package/dist/server/Methods.js.map +1 -1
  94. package/dist/server/Mppx.d.ts.map +1 -1
  95. package/dist/server/Mppx.js +90 -12
  96. package/dist/server/Mppx.js.map +1 -1
  97. package/dist/server/Transport.d.ts +10 -0
  98. package/dist/server/Transport.d.ts.map +1 -1
  99. package/dist/server/Transport.js +9 -0
  100. package/dist/server/Transport.js.map +1 -1
  101. package/dist/server/index.d.ts +1 -1
  102. package/dist/server/index.d.ts.map +1 -1
  103. package/dist/server/index.js +1 -1
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  106. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  107. package/dist/stripe/server/internal/html.gen.js +1 -1
  108. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  109. package/dist/tempo/client/ChannelOps.d.ts +3 -3
  110. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  111. package/dist/tempo/client/ChannelOps.js +13 -6
  112. package/dist/tempo/client/ChannelOps.js.map +1 -1
  113. package/dist/tempo/client/Charge.d.ts.map +1 -1
  114. package/dist/tempo/client/Charge.js +8 -5
  115. package/dist/tempo/client/Charge.js.map +1 -1
  116. package/dist/tempo/client/Methods.d.ts +0 -1
  117. package/dist/tempo/client/Methods.d.ts.map +1 -1
  118. package/dist/tempo/client/Session.d.ts +2 -4
  119. package/dist/tempo/client/Session.d.ts.map +1 -1
  120. package/dist/tempo/client/Session.js +10 -12
  121. package/dist/tempo/client/Session.js.map +1 -1
  122. package/dist/tempo/client/SessionManager.d.ts +3 -3
  123. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  124. package/dist/tempo/client/SessionManager.js +1 -1
  125. package/dist/tempo/client/SessionManager.js.map +1 -1
  126. package/dist/tempo/internal/account.d.ts +5 -0
  127. package/dist/tempo/internal/account.d.ts.map +1 -1
  128. package/dist/tempo/internal/account.js +8 -0
  129. package/dist/tempo/internal/account.js.map +1 -1
  130. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  131. package/dist/tempo/internal/fee-payer.js +5 -2
  132. package/dist/tempo/internal/fee-payer.js.map +1 -1
  133. package/dist/tempo/server/Charge.d.ts.map +1 -1
  134. package/dist/tempo/server/Charge.js +23 -1
  135. package/dist/tempo/server/Charge.js.map +1 -1
  136. package/dist/tempo/server/Session.d.ts.map +1 -1
  137. package/dist/tempo/server/Session.js +13 -12
  138. package/dist/tempo/server/Session.js.map +1 -1
  139. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  140. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  141. package/dist/tempo/server/internal/html.gen.js +1 -1
  142. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  143. package/dist/tempo/session/Chain.d.ts +2 -0
  144. package/dist/tempo/session/Chain.d.ts.map +1 -1
  145. package/dist/tempo/session/Chain.js +8 -8
  146. package/dist/tempo/session/Chain.js.map +1 -1
  147. package/dist/tempo/session/Voucher.d.ts +4 -3
  148. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  149. package/dist/tempo/session/Voucher.js +71 -44
  150. package/dist/tempo/session/Voucher.js.map +1 -1
  151. package/dist/tempo/session/Ws.d.ts.map +1 -1
  152. package/dist/tempo/session/Ws.js +15 -0
  153. package/dist/tempo/session/Ws.js.map +1 -1
  154. package/dist/tempo/subscription/KeyAuthorization.d.ts +2 -2
  155. package/dist/x402/Assets.d.ts +29 -0
  156. package/dist/x402/Assets.d.ts.map +1 -0
  157. package/dist/x402/Assets.js +46 -0
  158. package/dist/x402/Assets.js.map +1 -0
  159. package/dist/x402/Header.d.ts +14 -0
  160. package/dist/x402/Header.d.ts.map +1 -0
  161. package/dist/x402/Header.js +18 -0
  162. package/dist/x402/Header.js.map +1 -0
  163. package/dist/x402/Types.d.ts +289 -0
  164. package/dist/x402/Types.d.ts.map +1 -0
  165. package/dist/x402/Types.js +139 -0
  166. package/dist/x402/Types.js.map +1 -0
  167. package/dist/x402/client/Exact.d.ts +38 -0
  168. package/dist/x402/client/Exact.d.ts.map +1 -0
  169. package/dist/x402/client/Exact.js +141 -0
  170. package/dist/x402/client/Exact.js.map +1 -0
  171. package/dist/x402/index.d.ts +6 -0
  172. package/dist/x402/index.d.ts.map +1 -0
  173. package/dist/x402/index.js +6 -0
  174. package/dist/x402/index.js.map +1 -0
  175. package/dist/x402/internal/ChallengeBrand.d.ts +5 -0
  176. package/dist/x402/internal/ChallengeBrand.d.ts.map +1 -0
  177. package/dist/x402/internal/ChallengeBrand.js +13 -0
  178. package/dist/x402/internal/ChallengeBrand.js.map +1 -0
  179. package/dist/x402/internal/RouteBinding.d.ts +8 -0
  180. package/dist/x402/internal/RouteBinding.d.ts.map +1 -0
  181. package/dist/x402/internal/RouteBinding.js +12 -0
  182. package/dist/x402/internal/RouteBinding.js.map +1 -0
  183. package/dist/x402/server/EvmCharge.d.ts +50 -0
  184. package/dist/x402/server/EvmCharge.d.ts.map +1 -0
  185. package/dist/x402/server/EvmCharge.js +301 -0
  186. package/dist/x402/server/EvmCharge.js.map +1 -0
  187. package/dist/x402/server/Facilitator.d.ts +12 -0
  188. package/dist/x402/server/Facilitator.d.ts.map +1 -0
  189. package/dist/x402/server/Facilitator.js +42 -0
  190. package/dist/x402/server/Facilitator.js.map +1 -0
  191. package/package.json +41 -21
  192. package/src/Challenge.test.ts +28 -0
  193. package/src/Challenge.ts +17 -10
  194. package/src/Method.ts +1 -1
  195. package/src/client/Methods.ts +1 -0
  196. package/src/client/Mppx.ts +4 -3
  197. package/src/client/Transport.test.ts +165 -30
  198. package/src/client/Transport.ts +76 -8
  199. package/src/client/index.ts +1 -1
  200. package/src/client/internal/Fetch.test.ts +31 -2
  201. package/src/client/internal/Fetch.ts +26 -19
  202. package/src/evm/Assets.ts +1 -0
  203. package/src/evm/Chains.ts +5 -0
  204. package/src/evm/Methods.ts +44 -0
  205. package/src/evm/PublicInterface.test-d.ts +109 -0
  206. package/src/evm/Types.ts +140 -0
  207. package/src/evm/client/Charge.test.ts +99 -0
  208. package/src/evm/client/Charge.ts +198 -0
  209. package/src/evm/client/Methods.ts +19 -0
  210. package/src/evm/client/index.ts +5 -0
  211. package/src/evm/index.ts +13 -0
  212. package/src/evm/server/Charge.test.ts +199 -0
  213. package/src/evm/server/Charge.ts +283 -0
  214. package/src/evm/server/Methods.ts +22 -0
  215. package/src/evm/server/index.ts +5 -0
  216. package/src/index.ts +2 -0
  217. package/src/internal/HeaderCodec.ts +36 -0
  218. package/src/middlewares/elysia.test.ts +25 -0
  219. package/src/middlewares/elysia.ts +1 -2
  220. package/src/middlewares/express.test.ts +28 -0
  221. package/src/middlewares/express.ts +1 -1
  222. package/src/middlewares/hono.test.ts +138 -2
  223. package/src/middlewares/nextjs.test.ts +22 -0
  224. package/src/proxy/internal/Headers.test.ts +20 -0
  225. package/src/proxy/internal/Headers.ts +12 -1
  226. package/src/proxy/services/openai.test.ts +57 -1
  227. package/src/proxy/services/openai.ts +2 -0
  228. package/src/server/Methods.ts +1 -0
  229. package/src/server/Mppx.test.ts +244 -1
  230. package/src/server/Mppx.ts +124 -11
  231. package/src/server/NodeListener.test.ts +28 -1
  232. package/src/server/Transport.test.ts +19 -0
  233. package/src/server/Transport.ts +20 -0
  234. package/src/server/index.ts +1 -1
  235. package/src/stripe/server/internal/html.gen.ts +1 -1
  236. package/src/tempo/AccessKeyAuthorization.test.ts +231 -0
  237. package/src/tempo/client/ChannelOps.test.ts +61 -7
  238. package/src/tempo/client/ChannelOps.ts +18 -7
  239. package/src/tempo/client/Charge.test.ts +126 -0
  240. package/src/tempo/client/Charge.ts +10 -6
  241. package/src/tempo/client/Session.test.ts +130 -1
  242. package/src/tempo/client/Session.ts +12 -19
  243. package/src/tempo/client/SessionManager.test.ts +69 -2
  244. package/src/tempo/client/SessionManager.ts +4 -4
  245. package/src/tempo/internal/account.ts +13 -0
  246. package/src/tempo/internal/fee-payer.test.ts +32 -2
  247. package/src/tempo/internal/fee-payer.ts +6 -2
  248. package/src/tempo/server/Charge.test.ts +69 -0
  249. package/src/tempo/server/Charge.ts +32 -0
  250. package/src/tempo/server/Session.test.ts +30 -0
  251. package/src/tempo/server/Session.ts +15 -16
  252. package/src/tempo/server/internal/html.gen.ts +1 -1
  253. package/src/tempo/session/Chain.test.ts +4 -4
  254. package/src/tempo/session/Chain.ts +10 -6
  255. package/src/tempo/session/Voucher.test.ts +230 -1
  256. package/src/tempo/session/Voucher.ts +96 -48
  257. package/src/tempo/session/Ws.test.ts +71 -0
  258. package/src/tempo/session/Ws.ts +13 -0
  259. package/src/x402/Assets.ts +65 -0
  260. package/src/x402/Exact.e2e.test.ts +448 -0
  261. package/src/x402/Header.test.ts +73 -0
  262. package/src/x402/Header.ts +34 -0
  263. package/src/x402/PublicInterface.test-d.ts +39 -0
  264. package/src/x402/Types.ts +248 -0
  265. package/src/x402/client/Exact.test.ts +180 -0
  266. package/src/x402/client/Exact.ts +198 -0
  267. package/src/x402/index.ts +5 -0
  268. package/src/x402/internal/ChallengeBrand.ts +14 -0
  269. package/src/x402/internal/RouteBinding.ts +18 -0
  270. package/src/x402/server/EvmCharge.ts +394 -0
  271. package/src/x402/server/Facilitator.test.ts +111 -0
  272. package/src/x402/server/Facilitator.ts +56 -0
@@ -9,6 +9,17 @@ const hopByHopHeaders = new Set([
9
9
  'trailer',
10
10
  ])
11
11
 
12
+ // Payment credentials are consumed by the proxy and must never reach upstream services.
13
+ const paymentHeaders = new Set([
14
+ 'accept-payment',
15
+ 'authorization',
16
+ 'payment-receipt',
17
+ 'payment-required',
18
+ 'payment-response',
19
+ 'payment-signature',
20
+ 'www-authenticate',
21
+ ])
22
+
12
23
  /** Strips hop-by-hop, auth, encoding, cookie, and forwarding headers from a request before proxying upstream. */
13
24
  export function scrub(headers: Headers): Headers {
14
25
  const scrubbed = new Headers()
@@ -16,7 +27,7 @@ export function scrub(headers: Headers): Headers {
16
27
  for (const [name, value] of headers) {
17
28
  const lower = name.toLowerCase()
18
29
 
19
- if (lower === 'authorization') continue
30
+ if (paymentHeaders.has(lower)) continue
20
31
  if (lower === 'accept-encoding') continue
21
32
  if (lower === 'content-length') continue
22
33
  if (lower === 'cookie') continue
@@ -35,8 +35,64 @@ const mppx_client = Mppx_client.create({
35
35
  })
36
36
 
37
37
  let proxyServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
38
+ let upstreamServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
38
39
 
39
- afterEach(() => proxyServer?.close())
40
+ afterEach(() => {
41
+ proxyServer?.close()
42
+ upstreamServer?.close()
43
+ })
44
+
45
+ describe('openai', () => {
46
+ test('security: strips caller-supplied OpenAI tenant headers before proxying', async () => {
47
+ upstreamServer = await Http.createServer((req, res) => {
48
+ res.writeHead(200, { 'Content-Type': 'application/json' })
49
+ res.end(
50
+ JSON.stringify({
51
+ headers: {
52
+ authorization: req.headers.authorization,
53
+ organization: req.headers['openai-organization'],
54
+ project: req.headers['openai-project'],
55
+ },
56
+ }),
57
+ )
58
+ })
59
+
60
+ const proxy = ApiProxy.create({
61
+ services: [
62
+ openai({
63
+ apiKey: 'sk-test',
64
+ baseUrl: upstreamServer.url,
65
+ routes: {
66
+ 'POST /v1/chat/completions': true,
67
+ },
68
+ }),
69
+ ],
70
+ })
71
+ proxyServer = await Http.createServer(proxy.listener)
72
+
73
+ const res = await fetch(`${proxyServer.url}/openai/v1/chat/completions`, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'OpenAI-Organization': 'org_attacker',
78
+ 'OpenAI-Project': 'proj_attacker',
79
+ },
80
+ body: '{}',
81
+ })
82
+ expect(res.status).toBe(200)
83
+
84
+ const body = (await res.json()) as {
85
+ headers: {
86
+ authorization: string
87
+ organization?: string | string[] | undefined
88
+ project?: string | string[] | undefined
89
+ }
90
+ }
91
+ expect(body.headers.authorization).toBe('Bearer sk-test')
92
+ expect(body.headers.organization).toBeUndefined()
93
+ expect(body.headers.project).toBeUndefined()
94
+ })
95
+ })
40
96
 
41
97
  describe.skipIf(!apiKey)('openai', () => {
42
98
  test('behavior: proxies GET /v1/models with charge', async () => {
@@ -29,6 +29,8 @@ export function openai(config: openai.Config) {
29
29
  },
30
30
  rewriteRequest(request, ctx) {
31
31
  const apiKey = ctx.apiKey ?? config.apiKey
32
+ request.headers.delete('OpenAI-Organization')
33
+ request.headers.delete('OpenAI-Project')
32
34
  request.headers.set('Authorization', `Bearer ${apiKey}`)
33
35
  return request
34
36
  },
@@ -1,2 +1,3 @@
1
+ export { evm } from '../evm/server/index.js'
1
2
  export { stripe } from '../stripe/server/index.js'
2
3
  export { tempo } from '../tempo/server/index.js'
@@ -6,7 +6,9 @@ import {
6
6
  session as tempo_session_client,
7
7
  tempo as tempo_client,
8
8
  } from 'mppx/client'
9
- import { Mppx, stripe, Store, Transport, tempo } from 'mppx/server'
9
+ import { Types as evm_Types } from 'mppx/evm'
10
+ import { evm, Mppx, stripe, Store, Transport, tempo } from 'mppx/server'
11
+ import { Header as x402_Header, Types as x402_Types, type PaymentPayload } from 'mppx/x402'
10
12
  import { getTransactionReceipt } from 'viem/actions'
11
13
  import { describe, expect, test } from 'vp/test'
12
14
  import * as Http from '~test/Http.js'
@@ -14,6 +16,7 @@ import { deployEscrow } from '~test/tempo/session.js'
14
16
  import { accounts, asset, client } from '~test/tempo/viem.js'
15
17
 
16
18
  import type { SessionReceipt } from '../tempo/session/Types.js'
19
+ import * as x402_RouteBinding from '../x402/internal/RouteBinding.js'
17
20
 
18
21
  const realm = 'api.example.com'
19
22
  const secretKey = 'test-secret-key'
@@ -1855,6 +1858,29 @@ describe('compose', () => {
1855
1858
  },
1856
1859
  })
1857
1860
 
1861
+ const x402Method = evm.charge({
1862
+ currency: evm.assets.baseSepolia.USDC,
1863
+ recipient: accounts[0].address,
1864
+ x402: {
1865
+ facilitator: {
1866
+ async verify(paymentPayload: PaymentPayload) {
1867
+ return {
1868
+ isValid: true,
1869
+ payer: payerOf(paymentPayload),
1870
+ }
1871
+ },
1872
+ async settle(paymentPayload: PaymentPayload) {
1873
+ return {
1874
+ network: paymentPayload.accepted.network,
1875
+ payer: payerOf(paymentPayload),
1876
+ success: true,
1877
+ transaction: `0x${'3'.repeat(64)}`,
1878
+ }
1879
+ },
1880
+ },
1881
+ },
1882
+ })
1883
+
1858
1884
  const challengeOpts = {
1859
1885
  amount: '1000',
1860
1886
  currency: '0x0000000000000000000000000000000000000001',
@@ -1879,6 +1905,142 @@ describe('compose', () => {
1879
1905
  expect(wwwAuth).toContain('method="beta"')
1880
1906
  })
1881
1907
 
1908
+ test('returns composed x402 challenge headers when no credential', async () => {
1909
+ const mppx = Mppx.create({ methods: [x402Method], realm, secretKey })
1910
+
1911
+ const result = await mppx.compose(['evm/charge', { amount: '0.01' }])(
1912
+ new Request('https://example.com/resource'),
1913
+ )
1914
+
1915
+ expect(result.status).toBe(402)
1916
+ if (result.status !== 402) throw new Error()
1917
+
1918
+ expect(result.challenge.headers.get('WWW-Authenticate')).toContain('method="evm"')
1919
+ const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader)
1920
+ expect(header).toBeTruthy()
1921
+ expect(result.challenge.headers.get('Content-Type')).toContain('application/problem+json')
1922
+ expect(await result.challenge.json()).toMatchObject({ status: 402 })
1923
+
1924
+ const paymentRequired = x402_Header.decodePaymentRequired(header!)
1925
+ expect(paymentRequired.accepts).toHaveLength(1)
1926
+ expect(paymentRequired.accepts[0]).toMatchObject({
1927
+ amount: '10000',
1928
+ scheme: x402_Types.schemes[0],
1929
+ })
1930
+ expect(paymentRequired.resource.url).toBe('https://example.com/resource')
1931
+ })
1932
+
1933
+ test('merges Payment auth and x402 challenge headers in compose()', async () => {
1934
+ const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey })
1935
+
1936
+ const result = await mppx.compose(
1937
+ [alphaMethod, challengeOpts],
1938
+ ['evm/charge', { amount: '0.01' }],
1939
+ )(new Request('https://example.com/resource'))
1940
+
1941
+ expect(result.status).toBe(402)
1942
+ if (result.status !== 402) throw new Error()
1943
+
1944
+ const wwwAuth = result.challenge.headers.get('WWW-Authenticate')
1945
+ expect(wwwAuth).toContain('method="alpha"')
1946
+
1947
+ const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader)
1948
+ expect(header).toBeTruthy()
1949
+ const paymentRequired = x402_Header.decodePaymentRequired(header!)
1950
+ expect(paymentRequired.accepts.map((accepted) => accepted.amount)).toEqual(['10000'])
1951
+ })
1952
+
1953
+ test('keeps pure x402 challenge headers when Payment auth challenges are present', async () => {
1954
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
1955
+ const pureX402 = async () =>
1956
+ ({
1957
+ status: 402,
1958
+ challenge: new Response('{}', {
1959
+ status: 402,
1960
+ headers: {
1961
+ [x402_Types.paymentRequiredHeader]: x402_Header.encodePaymentRequired({
1962
+ accepts: [
1963
+ {
1964
+ amount: '10000',
1965
+ asset: evm.assets.baseSepolia.USDC.address,
1966
+ extra: {
1967
+ assetTransferMethod: evm_Types.eip3009,
1968
+ name: 'USDC',
1969
+ version: '2',
1970
+ },
1971
+ maxTimeoutSeconds: 300,
1972
+ network: 'eip155:84532',
1973
+ payTo: accounts[0].address,
1974
+ scheme: 'exact',
1975
+ },
1976
+ ],
1977
+ resource: { url: 'https://example.com/resource' },
1978
+ x402Version: 2,
1979
+ }),
1980
+ },
1981
+ }),
1982
+ }) as const
1983
+
1984
+ const alpha = (mppx as unknown as Record<string, (options: unknown) => any>)['alpha/charge']!(
1985
+ challengeOpts,
1986
+ )
1987
+
1988
+ const result = await Mppx.compose(alpha, pureX402)(new Request('https://example.com/resource'))
1989
+
1990
+ expect(result.status).toBe(402)
1991
+ if (result.status !== 402) throw new Error()
1992
+
1993
+ expect(result.challenge.headers.get('WWW-Authenticate')).toContain('method="alpha"')
1994
+ expect(result.challenge.headers.get(x402_Types.paymentRequiredHeader)).toBeTruthy()
1995
+ })
1996
+
1997
+ test('merges multiple x402 exact offers in compose()', async () => {
1998
+ const mppx = Mppx.create({ methods: [x402Method], realm, secretKey })
1999
+
2000
+ const result = await mppx.compose(
2001
+ ['evm/charge', { amount: '0.01' }],
2002
+ ['evm/charge', { amount: '0.02' }],
2003
+ )(new Request('https://example.com/resource'))
2004
+
2005
+ expect(result.status).toBe(402)
2006
+ if (result.status !== 402) throw new Error()
2007
+
2008
+ const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader)
2009
+ expect(header).toBeTruthy()
2010
+ const paymentRequired = x402_Header.decodePaymentRequired(header!)
2011
+ expect(paymentRequired.accepts.map((accepted) => accepted.amount)).toEqual(['10000', '20000'])
2012
+ })
2013
+
2014
+ test('dispatches x402 credentials through compose()', async () => {
2015
+ const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey })
2016
+ const handle = mppx.compose([alphaMethod, challengeOpts], ['evm/charge', { amount: '0.01' }])
2017
+
2018
+ const firstResult = await handle(new Request('https://example.com/resource'))
2019
+ expect(firstResult.status).toBe(402)
2020
+ if (firstResult.status !== 402) throw new Error()
2021
+
2022
+ const paymentRequired = x402_Header.decodePaymentRequired(
2023
+ firstResult.challenge.headers.get(x402_Types.paymentRequiredHeader)!,
2024
+ )
2025
+ const credential = await x402PaymentSignature(
2026
+ paymentRequired.accepts[0]!,
2027
+ paymentRequired.resource,
2028
+ paymentRequired.extensions,
2029
+ )
2030
+
2031
+ const result = await handle(
2032
+ new Request('https://example.com/resource', {
2033
+ headers: { [x402_Types.paymentSignatureHeader]: credential },
2034
+ }),
2035
+ )
2036
+
2037
+ expect(result.status).toBe(200)
2038
+ if (result.status !== 200) throw new Error()
2039
+ const response = result.withReceipt(new Response('paid'))
2040
+ expect(response.headers.get(x402_Types.paymentResponseHeader)).toBeTruthy()
2041
+ expect(await response.text()).toBe('paid')
2042
+ })
2043
+
1882
2044
  test('filters compose challenges using Accept-Payment', async () => {
1883
2045
  const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1884
2046
 
@@ -1899,6 +2061,27 @@ describe('compose', () => {
1899
2061
  expect(challenges[0]?.method).toBe('beta')
1900
2062
  })
1901
2063
 
2064
+ test('filters compose x402 challenge headers using Accept-Payment', async () => {
2065
+ const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey })
2066
+
2067
+ const result = await mppx.compose(
2068
+ [alphaMethod, challengeOpts],
2069
+ ['evm/charge', { amount: '0.01' }],
2070
+ )(
2071
+ new Request('https://example.com/resource', {
2072
+ headers: { 'Accept-Payment': 'alpha/charge' },
2073
+ }),
2074
+ )
2075
+
2076
+ expect(result.status).toBe(402)
2077
+ if (result.status !== 402) throw new Error()
2078
+
2079
+ const challenges = Challenge.fromResponseList(result.challenge)
2080
+ expect(challenges).toHaveLength(1)
2081
+ expect(challenges[0]?.method).toBe('alpha')
2082
+ expect(result.challenge.headers.get(x402_Types.paymentRequiredHeader)).toBeNull()
2083
+ })
2084
+
1902
2085
  test('orders compose challenges by Accept-Payment q-value', async () => {
1903
2086
  const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1904
2087
 
@@ -2522,6 +2705,66 @@ describe('compose', () => {
2522
2705
  })
2523
2706
  })
2524
2707
 
2708
+ async function x402PaymentSignature(
2709
+ accepted: x402_Types.PaymentRequirements,
2710
+ resource: x402_Types.ResourceInfo,
2711
+ extensions: x402_Types.Extensions | undefined,
2712
+ ): Promise<string> {
2713
+ const authorization = {
2714
+ from: accounts[0].address,
2715
+ nonce: x402_RouteBinding.nonce({
2716
+ accepted,
2717
+ extensions: extensions!,
2718
+ resource,
2719
+ }),
2720
+ to: accepted.payTo as `0x${string}`,
2721
+ validAfter: '0',
2722
+ validBefore: '9999999999',
2723
+ value: accepted.amount,
2724
+ }
2725
+ const signature = await accounts[0].signTypedData({
2726
+ domain: {
2727
+ chainId: Number(accepted.network.slice(x402_Types.evmNetworkPrefix.length)),
2728
+ name: accepted.extra?.name as string,
2729
+ verifyingContract: accepted.asset as `0x${string}`,
2730
+ version: accepted.extra?.version as string,
2731
+ },
2732
+ message: {
2733
+ ...authorization,
2734
+ validAfter: BigInt(authorization.validAfter),
2735
+ validBefore: BigInt(authorization.validBefore),
2736
+ value: BigInt(authorization.value),
2737
+ },
2738
+ primaryType: 'TransferWithAuthorization',
2739
+ types: {
2740
+ TransferWithAuthorization: [
2741
+ { name: 'from', type: 'address' },
2742
+ { name: 'to', type: 'address' },
2743
+ { name: 'value', type: 'uint256' },
2744
+ { name: 'validAfter', type: 'uint256' },
2745
+ { name: 'validBefore', type: 'uint256' },
2746
+ { name: 'nonce', type: 'bytes32' },
2747
+ ],
2748
+ },
2749
+ })
2750
+
2751
+ return x402_Header.encodePaymentSignature({
2752
+ accepted,
2753
+ ...(extensions ? { extensions } : {}),
2754
+ payload: {
2755
+ authorization,
2756
+ signature,
2757
+ },
2758
+ resource,
2759
+ x402Version: 2,
2760
+ })
2761
+ }
2762
+
2763
+ function payerOf(paymentPayload: PaymentPayload): string {
2764
+ if ('authorization' in paymentPayload.payload) return paymentPayload.payload.authorization.from
2765
+ return paymentPayload.payload.permit2Authorization.from
2766
+ }
2767
+
2525
2768
  describe('compose: pre-dispatch narrowing edge cases', () => {
2526
2769
  const mockCharge = Method.from({
2527
2770
  name: 'alpha',
@@ -11,6 +11,8 @@ import type { MaybePromise } from '../internal/types.js'
11
11
  import type * as Method from '../Method.js'
12
12
  import * as PaymentRequest from '../PaymentRequest.js'
13
13
  import type * as Receipt from '../Receipt.js'
14
+ import * as x402_Header from '../x402/Header.js'
15
+ import * as x402_Types from '../x402/Types.js'
14
16
  import * as z from '../zod.js'
15
17
  import * as Html from './internal/html/config.js'
16
18
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
@@ -790,7 +792,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
790
792
  : staticMeta
791
793
 
792
794
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
793
- const [credential, credentialError] = (() => {
795
+ let [credential, credentialError] = (() => {
794
796
  try {
795
797
  const credential = transport.getCredential(input) as Credential.Credential | null
796
798
  return [credential ? hydrateCredentialMeta(credential) : null, undefined] as const
@@ -898,6 +900,21 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
898
900
  if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 }
899
901
  const { challenge, parsedRequest, request } = routeChallenge
900
902
 
903
+ if (credential && transport.bindCredential) {
904
+ try {
905
+ credential = hydrateCredentialMeta(
906
+ (await transport.bindCredential({
907
+ challenge,
908
+ credential,
909
+ input,
910
+ })) as Credential.Credential,
911
+ )
912
+ } catch (e) {
913
+ credential = null
914
+ credentialError = e as Error
915
+ }
916
+ }
917
+
901
918
  // Credential was provided but malformed
902
919
  if (credentialError) {
903
920
  const reason = getSafeCredentialReason(credentialError)
@@ -2004,6 +2021,29 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
2004
2021
  }
2005
2022
  }
2006
2023
 
2024
+ const paymentAuthChallengeHeader = 'WWW-Authenticate'
2025
+
2026
+ const challengeHeaderMerges = [
2027
+ {
2028
+ name: paymentAuthChallengeHeader,
2029
+ values: (context) =>
2030
+ context.challengeEntries
2031
+ .map((entry) =>
2032
+ (entry.result.challenge as Response).headers.get(paymentAuthChallengeHeader),
2033
+ )
2034
+ .filter((value): value is string => value !== null),
2035
+ merge: (values) => values,
2036
+ },
2037
+ {
2038
+ name: x402_Types.paymentRequiredHeader,
2039
+ values: (context) =>
2040
+ context.negotiatedChallengeResponses
2041
+ .map((response) => response.headers.get(x402_Types.paymentRequiredHeader))
2042
+ .filter((value): value is string => value !== null),
2043
+ merge: mergeX402PaymentRequiredHeaders,
2044
+ },
2045
+ ] satisfies readonly ChallengeHeaderMerge[]
2046
+
2007
2047
  /** An entry for `compose()`: a method reference, handler function ref, or string key paired with its options. */
2008
2048
  type ComposeEntry<methods extends readonly Method.AnyServer[]> =
2009
2049
  | {
@@ -2172,7 +2212,7 @@ export function compose(
2172
2212
  if (result?.status !== 402) continue
2173
2213
 
2174
2214
  const response = result.challenge as Response
2175
- const wwwAuth = response.headers.get('WWW-Authenticate')
2215
+ const wwwAuth = response.headers.get(paymentAuthChallengeHeader)
2176
2216
  if (!wwwAuth) continue
2177
2217
 
2178
2218
  entries.push({
@@ -2199,14 +2239,39 @@ export function compose(
2199
2239
  }
2200
2240
  })()
2201
2241
 
2202
- // Merge WWW-Authenticate headers from all 402 responses.
2242
+ const challengeResponses = results.flatMap((result) =>
2243
+ result.status === 402 ? [result.challenge as Response] : [],
2244
+ )
2245
+ const unnegotiatedX402Responses =
2246
+ input.headers.has('Accept-Payment') || challengeEntries.length === 0
2247
+ ? []
2248
+ : challengeResponses.filter(
2249
+ (response) =>
2250
+ response.headers.has(x402_Types.paymentRequiredHeader) &&
2251
+ !response.headers.has(paymentAuthChallengeHeader),
2252
+ )
2253
+ const negotiatedChallengeResponses =
2254
+ challengeEntries.length > 0
2255
+ ? [
2256
+ ...challengeEntries.map((entry) => entry.result.challenge as Response),
2257
+ ...unnegotiatedX402Responses,
2258
+ ]
2259
+ : challengeResponses
2260
+
2261
+ // Merge challenge headers from the negotiated 402 responses.
2203
2262
  const mergedHeaders = new Headers()
2204
2263
  mergedHeaders.set('Cache-Control', 'no-store')
2205
2264
 
2206
- for (const entry of challengeEntries) {
2207
- const response = entry.result.challenge as Response
2208
- const wwwAuth = response.headers.get('WWW-Authenticate')
2209
- if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth)
2265
+ for (const header of challengeHeaderMerges) {
2266
+ for (const value of header.merge(
2267
+ header.values({
2268
+ challengeEntries,
2269
+ challengeResponses,
2270
+ negotiatedChallengeResponses,
2271
+ }),
2272
+ )) {
2273
+ mergedHeaders.append(header.name, value)
2274
+ }
2210
2275
  }
2211
2276
 
2212
2277
  // Collect html-enabled handlers and their challenges
@@ -2257,11 +2322,15 @@ export function compose(
2257
2322
  }
2258
2323
  }
2259
2324
 
2260
- // Non-HTML fallback: use first handler's body
2325
+ // Non-HTML fallback: prefer the first Payment-auth body, otherwise use
2326
+ // the first transport-specific 402 body.
2261
2327
  let body: string | null = null
2262
- for (const entry of challengeEntries) {
2328
+ const bodyResponses =
2329
+ challengeEntries.length > 0
2330
+ ? challengeEntries.map((entry) => entry.result.challenge as Response)
2331
+ : challengeResponses
2332
+ for (const response of bodyResponses) {
2263
2333
  if (!body) {
2264
- const response = entry.result.challenge as Response
2265
2334
  const contentType = response.headers.get('Content-Type')
2266
2335
  if (contentType) mergedHeaders.set('Content-Type', contentType)
2267
2336
  body = await response.text()
@@ -2276,6 +2345,50 @@ export function compose(
2276
2345
  }
2277
2346
  }
2278
2347
 
2348
+ type ChallengeHeaderMerge = {
2349
+ name: string
2350
+ values(context: {
2351
+ challengeEntries: readonly {
2352
+ handler: ConfiguredHandler
2353
+ challenge: Challenge.Challenge
2354
+ result: Extract<MethodFn.Response<Transport.Http>, { status: 402 }>
2355
+ }[]
2356
+ challengeResponses: readonly Response[]
2357
+ negotiatedChallengeResponses: readonly Response[]
2358
+ }): readonly string[]
2359
+ merge(values: readonly string[]): readonly string[]
2360
+ }
2361
+
2362
+ function mergeX402PaymentRequiredHeaders(values: readonly string[]): readonly string[] {
2363
+ if (values.length === 0) return []
2364
+ const [first, ...rest] = values.map((value) => x402_Header.decodePaymentRequired(value))
2365
+ if (!first) throw new Error('Expected at least one x402 payment-required header.')
2366
+ const incompatible = rest.some(
2367
+ (value) =>
2368
+ !isDeepStrictEqual(value.resource, first.resource) ||
2369
+ !isDeepStrictEqual(value.extensions, first.extensions),
2370
+ )
2371
+ if (incompatible)
2372
+ return [
2373
+ x402_Header.encodePaymentRequired({
2374
+ ...first,
2375
+ error: [first.error, 'Cannot merge x402 payment requirements with different resources.']
2376
+ .filter((value): value is string => value !== undefined && value.length > 0)
2377
+ .join('; '),
2378
+ }),
2379
+ ]
2380
+ const error = [first.error, ...rest.map((value) => value.error)]
2381
+ .filter((value): value is string => value !== undefined && value.length > 0)
2382
+ .join('; ')
2383
+ return [
2384
+ x402_Header.encodePaymentRequired({
2385
+ ...first,
2386
+ accepts: [first.accepts, ...rest.map((value) => value.accepts)].flat(),
2387
+ ...(error ? { error } : {}),
2388
+ }),
2389
+ ]
2390
+ }
2391
+
2279
2392
  /**
2280
2393
  * Wraps a payment handler to create a Node.js HTTP listener.
2281
2394
  *
@@ -2316,7 +2429,7 @@ export function toNodeListener(
2316
2429
  }
2317
2430
 
2318
2431
  const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response
2319
- res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!)
2432
+ for (const [name, value] of wrapped.headers) res.setHeader(name, value)
2320
2433
  }
2321
2434
 
2322
2435
  return result
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
 
4
- import { NodeListener, Request } from 'mppx/server'
4
+ import { Mppx, NodeListener, Request } from 'mppx/server'
5
5
  import { afterEach, describe, expect, test } from 'vp/test'
6
6
  import * as Http from '~test/Http.js'
7
7
 
@@ -185,6 +185,33 @@ describe('toNodeListener', () => {
185
185
  expect(await response.json()).toEqual({ path: '/hello' })
186
186
  })
187
187
 
188
+ test('copies all receipt response headers for successful payment handlers', async () => {
189
+ const handler = Mppx.toNodeListener(async () => ({
190
+ status: 200,
191
+ withReceipt(response?: Response) {
192
+ if (!response) {
193
+ const error = new Error('withReceipt() requires a response argument')
194
+ error.name = 'MissingReceiptResponseError'
195
+ throw error
196
+ }
197
+ const headers = new Headers(response?.headers)
198
+ headers.set('Payment-Receipt', 'receipt')
199
+ headers.set('PAYMENT-RESPONSE', 'x402-response')
200
+ return new Response(response?.body, { headers })
201
+ },
202
+ }))
203
+
204
+ server = await Http.createServer(async (req, res) => {
205
+ await handler(req, res)
206
+ res.end('ok')
207
+ })
208
+
209
+ const response = await fetch(server.url)
210
+ expect(response.headers.get('Payment-Receipt')).toBe('receipt')
211
+ expect(response.headers.get('PAYMENT-RESPONSE')).toBe('x402-response')
212
+ expect(await response.text()).toBe('ok')
213
+ })
214
+
188
215
  test('forwards request method', async () => {
189
216
  const handler = Request.toNodeListener(async (request) => {
190
217
  return Response.json({ method: request.method })
@@ -408,6 +408,7 @@ describe('http', () => {
408
408
  }).toMatchInlineSnapshot(`
409
409
  {
410
410
  "headers": {
411
+ "cache-control": "private",
411
412
  "content-type": "text/plain;charset=UTF-8",
412
413
  "payment-receipt": "eyJtZXRob2QiOiJ0ZW1wbyIsInJlZmVyZW5jZSI6IjB4dHhoYXNoIiwic3RhdHVzIjoic3VjY2VzcyIsInRpbWVzdGFtcCI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiJ9",
413
414
  },
@@ -415,6 +416,24 @@ describe('http', () => {
415
416
  }
416
417
  `)
417
418
  })
419
+
420
+ test('preserves existing cache directives while marking receipts private', () => {
421
+ const transport = Transport.http()
422
+ const originalResponse = new Response('OK', {
423
+ status: 200,
424
+ headers: { 'Cache-Control': 'max-age=60' },
425
+ })
426
+
427
+ const response = transport.respondReceipt({
428
+ credential,
429
+ input: new Request('https://example.com'),
430
+ receipt,
431
+ response: originalResponse,
432
+ challengeId: challenge.id,
433
+ })
434
+
435
+ expect(response.headers.get('Cache-Control')).toBe('max-age=60, private')
436
+ })
418
437
  })
419
438
  })
420
439