mppx 0.4.9 → 0.4.10

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 (158) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +155 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/discovery/Discovery.d.ts +146 -0
  6. package/dist/discovery/Discovery.d.ts.map +1 -0
  7. package/dist/discovery/Discovery.js +60 -0
  8. package/dist/discovery/Discovery.js.map +1 -0
  9. package/dist/discovery/OpenApi.d.ts +61 -0
  10. package/dist/discovery/OpenApi.d.ts.map +1 -0
  11. package/dist/discovery/OpenApi.js +139 -0
  12. package/dist/discovery/OpenApi.js.map +1 -0
  13. package/dist/discovery/Validate.d.ts +10 -0
  14. package/dist/discovery/Validate.d.ts.map +1 -0
  15. package/dist/discovery/Validate.js +63 -0
  16. package/dist/discovery/Validate.js.map +1 -0
  17. package/dist/discovery/index.d.ts +4 -0
  18. package/dist/discovery/index.d.ts.map +1 -0
  19. package/dist/discovery/index.js +4 -0
  20. package/dist/discovery/index.js.map +1 -0
  21. package/dist/middlewares/elysia.d.ts +52 -1
  22. package/dist/middlewares/elysia.d.ts.map +1 -1
  23. package/dist/middlewares/elysia.js +17 -0
  24. package/dist/middlewares/elysia.js.map +1 -1
  25. package/dist/middlewares/express.d.ts +13 -1
  26. package/dist/middlewares/express.d.ts.map +1 -1
  27. package/dist/middlewares/express.js +18 -0
  28. package/dist/middlewares/express.js.map +1 -1
  29. package/dist/middlewares/hono.d.ts +19 -1
  30. package/dist/middlewares/hono.d.ts.map +1 -1
  31. package/dist/middlewares/hono.js +51 -0
  32. package/dist/middlewares/hono.js.map +1 -1
  33. package/dist/middlewares/internal/mppx.d.ts +4 -2
  34. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  35. package/dist/middlewares/internal/mppx.js +10 -3
  36. package/dist/middlewares/internal/mppx.js.map +1 -1
  37. package/dist/middlewares/nextjs.d.ts +11 -0
  38. package/dist/middlewares/nextjs.d.ts.map +1 -1
  39. package/dist/middlewares/nextjs.js +15 -0
  40. package/dist/middlewares/nextjs.js.map +1 -1
  41. package/dist/proxy/Proxy.d.ts +6 -0
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +56 -80
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts +16 -23
  46. package/dist/proxy/Service.d.ts.map +1 -1
  47. package/dist/proxy/Service.js +19 -83
  48. package/dist/proxy/Service.js.map +1 -1
  49. package/dist/proxy/internal/Route.js +1 -1
  50. package/dist/proxy/internal/Route.js.map +1 -1
  51. package/dist/proxy/services/anthropic.d.ts.map +1 -1
  52. package/dist/proxy/services/anthropic.js +5 -0
  53. package/dist/proxy/services/anthropic.js.map +1 -1
  54. package/dist/proxy/services/openai.d.ts.map +1 -1
  55. package/dist/proxy/services/openai.js +6 -3
  56. package/dist/proxy/services/openai.js.map +1 -1
  57. package/dist/proxy/services/stripe.d.ts.map +1 -1
  58. package/dist/proxy/services/stripe.js +6 -3
  59. package/dist/proxy/services/stripe.js.map +1 -1
  60. package/dist/tempo/server/Session.d.ts.map +1 -1
  61. package/dist/tempo/server/Session.js +18 -5
  62. package/dist/tempo/server/Session.js.map +1 -1
  63. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  64. package/dist/tempo/server/internal/transport.js +8 -0
  65. package/dist/tempo/server/internal/transport.js.map +1 -1
  66. package/dist/tempo/session/Chain.js +1 -1
  67. package/dist/tempo/session/Chain.js.map +1 -1
  68. package/package.json +6 -1
  69. package/src/BodyDigest.test.ts +1 -1
  70. package/src/Challenge.fuzz.test.ts +121 -0
  71. package/src/Challenge.test-d.ts +1 -1
  72. package/src/Challenge.test.ts +1 -1
  73. package/src/Credential.fuzz.test.ts +62 -0
  74. package/src/Credential.test.ts +1 -1
  75. package/src/Errors.test.ts +1 -1
  76. package/src/Expires.test.ts +1 -1
  77. package/src/Method.test.ts +1 -1
  78. package/src/PaymentRequest.test.ts +1 -1
  79. package/src/Receipt.test.ts +1 -1
  80. package/src/Store.test-d.ts +1 -1
  81. package/src/Store.test.ts +1 -1
  82. package/src/cli/cli.test.ts +212 -1
  83. package/src/cli/cli.ts +162 -0
  84. package/src/client/Mppx.test-d.ts +1 -1
  85. package/src/client/Mppx.test.ts +1 -1
  86. package/src/client/Transport.test.ts +1 -1
  87. package/src/client/internal/Fetch.browser.test.ts +1 -1
  88. package/src/client/internal/Fetch.test-d.ts +1 -1
  89. package/src/client/internal/Fetch.test.ts +2 -1
  90. package/src/discovery/Discovery.test.ts +152 -0
  91. package/src/discovery/Discovery.ts +72 -0
  92. package/src/discovery/OpenApi.test.ts +425 -0
  93. package/src/discovery/OpenApi.ts +224 -0
  94. package/src/discovery/Validate.test.ts +188 -0
  95. package/src/discovery/Validate.ts +76 -0
  96. package/src/discovery/index.ts +3 -0
  97. package/src/internal/constantTimeEqual.test.ts +1 -1
  98. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
  99. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  100. package/src/mcp-sdk/server/Transport.test.ts +1 -1
  101. package/src/middlewares/elysia.test.ts +27 -2
  102. package/src/middlewares/elysia.ts +35 -1
  103. package/src/middlewares/express.test.ts +35 -7
  104. package/src/middlewares/express.ts +34 -0
  105. package/src/middlewares/hono.test.ts +28 -6
  106. package/src/middlewares/hono.ts +73 -1
  107. package/src/middlewares/internal/mppx.test.ts +1 -1
  108. package/src/middlewares/internal/mppx.ts +14 -6
  109. package/src/middlewares/nextjs.test.ts +31 -6
  110. package/src/middlewares/nextjs.ts +28 -0
  111. package/src/proxy/Proxy.test.ts +54 -270
  112. package/src/proxy/Proxy.ts +71 -93
  113. package/src/proxy/Service.test.ts +23 -1
  114. package/src/proxy/Service.ts +40 -86
  115. package/src/proxy/internal/Headers.test.ts +1 -1
  116. package/src/proxy/internal/Route.test.ts +9 -1
  117. package/src/proxy/internal/Route.ts +1 -1
  118. package/src/proxy/services/anthropic.test.ts +132 -0
  119. package/src/proxy/services/anthropic.ts +5 -0
  120. package/src/proxy/services/openai.test.ts +1 -1
  121. package/src/proxy/services/openai.ts +6 -4
  122. package/src/proxy/services/stripe.test.ts +132 -0
  123. package/src/proxy/services/stripe.ts +6 -4
  124. package/src/server/Mppx.test-d.ts +1 -1
  125. package/src/server/Mppx.test.ts +2 -1
  126. package/src/server/NodeListener.test.ts +1 -1
  127. package/src/server/Request.test.ts +1 -1
  128. package/src/server/Response.test.ts +1 -1
  129. package/src/server/Transport.test.ts +1 -1
  130. package/src/stripe/Charge.integration.test.ts +1 -1
  131. package/src/stripe/Methods.test.ts +1 -1
  132. package/src/stripe/client/Charge.test.ts +1 -1
  133. package/src/stripe/server/Charge.test.ts +1 -1
  134. package/src/tempo/Attribution.test.ts +1 -1
  135. package/src/tempo/Methods.test.ts +1 -1
  136. package/src/tempo/client/ChannelOps.test.ts +6 -3
  137. package/src/tempo/client/Session.test.ts +5 -2
  138. package/src/tempo/client/SessionManager.test.ts +1 -1
  139. package/src/tempo/internal/auto-swap.test.ts +1 -1
  140. package/src/tempo/internal/defaults.test.ts +1 -1
  141. package/src/tempo/internal/fee-payer.test.ts +1 -1
  142. package/src/tempo/server/Charge.test.ts +1 -1
  143. package/src/tempo/server/Session.test.ts +87 -37
  144. package/src/tempo/server/Session.ts +25 -8
  145. package/src/tempo/server/Sse.test.ts +1 -1
  146. package/src/tempo/server/internal/transport.test.ts +24 -1
  147. package/src/tempo/server/internal/transport.ts +11 -0
  148. package/src/tempo/session/Chain.test.ts +5 -2
  149. package/src/tempo/session/Chain.ts +1 -1
  150. package/src/tempo/session/Channel.test.ts +1 -1
  151. package/src/tempo/session/ChannelStore.test.ts +1 -1
  152. package/src/tempo/session/Receipt.test.ts +1 -1
  153. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  154. package/src/tempo/session/Sse.test.ts +1 -1
  155. package/src/tempo/session/Voucher.test.ts +1 -1
  156. package/src/viem/Account.test.ts +1 -1
  157. package/src/viem/Client.test.ts +1 -1
  158. package/src/zod.test.ts +147 -0
@@ -0,0 +1,188 @@
1
+ import { validate } from './Validate.js'
2
+
3
+ function makeDoc(overrides: Record<string, unknown> = {}) {
4
+ return {
5
+ info: { title: 'Test', version: '1.0.0' },
6
+ openapi: '3.1.0',
7
+ paths: {
8
+ '/search': {
9
+ post: {
10
+ 'x-payment-info': {
11
+ amount: '100',
12
+ intent: 'charge',
13
+ method: 'tempo',
14
+ },
15
+ requestBody: {
16
+ content: { 'application/json': { schema: { type: 'object' } } },
17
+ },
18
+ responses: {
19
+ '200': { description: 'OK' },
20
+ '402': { description: 'Payment Required' },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('validate', () => {
30
+ test('returns no errors for a valid document', () => {
31
+ const errors = validate(makeDoc())
32
+ expect(errors.filter((error) => error.severity === 'error')).toHaveLength(0)
33
+ })
34
+
35
+ test('returns error for missing 402 response', () => {
36
+ const errors = validate(
37
+ makeDoc({
38
+ paths: {
39
+ '/search': {
40
+ post: {
41
+ 'x-payment-info': {
42
+ amount: '100',
43
+ intent: 'charge',
44
+ method: 'tempo',
45
+ },
46
+ requestBody: {},
47
+ responses: {
48
+ '200': { description: 'OK' },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ }),
54
+ )
55
+
56
+ expect(errors.find((error) => error.severity === 'error')?.message).toContain('402')
57
+ })
58
+
59
+ test('returns warning for missing requestBody', () => {
60
+ const errors = validate(
61
+ makeDoc({
62
+ paths: {
63
+ '/search': {
64
+ post: {
65
+ 'x-payment-info': {
66
+ amount: '100',
67
+ intent: 'charge',
68
+ method: 'tempo',
69
+ },
70
+ responses: {
71
+ '200': { description: 'OK' },
72
+ '402': { description: 'Payment Required' },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ }),
78
+ )
79
+
80
+ expect(errors.find((error) => error.severity === 'warning')?.message).toContain('requestBody')
81
+ })
82
+
83
+ test('returns structural errors for invalid top-level document', () => {
84
+ const errors = validate({ openapi: '3.1.0' })
85
+ expect(errors.length).toBeGreaterThan(0)
86
+ expect(errors[0]!.severity).toBe('error')
87
+ })
88
+
89
+ test('returns errors for invalid extension values', () => {
90
+ const errors = validate(
91
+ makeDoc({
92
+ 'x-service-info': {
93
+ docs: { homepage: 'not-a-uri' },
94
+ },
95
+ paths: {
96
+ '/search': {
97
+ post: {
98
+ 'x-payment-info': {
99
+ amount: '01',
100
+ intent: 'subscribe',
101
+ method: 'tempo',
102
+ },
103
+ responses: {
104
+ '402': { description: 'Payment Required' },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ }),
110
+ )
111
+
112
+ expect(errors.some((error) => error.severity === 'error')).toBe(true)
113
+ })
114
+
115
+ test('ignores path-item-level fields like summary and parameters', () => {
116
+ const errors = validate(
117
+ makeDoc({
118
+ paths: {
119
+ '/search': {
120
+ summary: 'Search endpoints',
121
+ parameters: [{ name: 'q', in: 'query' }],
122
+ 'x-custom': 'value',
123
+ post: {
124
+ 'x-payment-info': {
125
+ amount: '100',
126
+ intent: 'charge',
127
+ method: 'tempo',
128
+ },
129
+ requestBody: {
130
+ content: { 'application/json': { schema: { type: 'object' } } },
131
+ },
132
+ responses: {
133
+ '200': { description: 'OK' },
134
+ '402': { description: 'Payment Required' },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ }),
140
+ )
141
+
142
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
143
+ })
144
+
145
+ test('validates proxy-generated docs with relative llms path', () => {
146
+ const errors = validate({
147
+ info: { title: 'API Proxy', version: '1.0.0' },
148
+ openapi: '3.1.0',
149
+ paths: {},
150
+ 'x-service-info': {
151
+ categories: ['gateway'],
152
+ docs: {
153
+ apiReference: 'https://example.com/api',
154
+ homepage: 'https://example.com',
155
+ llms: '/llms.txt',
156
+ },
157
+ },
158
+ })
159
+
160
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
161
+ })
162
+
163
+ test('accepts x-payment-info with unknown fields', () => {
164
+ const errors = validate({
165
+ info: { title: 'Test', version: '1.0.0' },
166
+ openapi: '3.1.0',
167
+ paths: {
168
+ '/api/call': {
169
+ post: {
170
+ 'x-payment-info': {
171
+ price: '0.54',
172
+ pricingMode: 'fixed',
173
+ protocols: ['x402', 'mpp'],
174
+ },
175
+ requestBody: {
176
+ content: { 'application/json': { schema: { type: 'object' } } },
177
+ },
178
+ responses: {
179
+ '200': { description: 'OK' },
180
+ '402': { description: 'Payment Required' },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ })
186
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
187
+ })
188
+ })
@@ -0,0 +1,76 @@
1
+ import { DiscoveryDocument, PaymentInfo } from './Discovery.js'
2
+
3
+ export type ValidationError = {
4
+ message: string
5
+ path: string
6
+ severity: 'error' | 'warning'
7
+ }
8
+
9
+ /**
10
+ * Validates a discovery document structurally and semantically.
11
+ */
12
+ export function validate(doc: unknown): ValidationError[] {
13
+ const errors: ValidationError[] = []
14
+
15
+ const result = DiscoveryDocument.safeParse(doc)
16
+ if (!result.success) {
17
+ for (const issue of result.error.issues) {
18
+ errors.push({
19
+ message: issue.message,
20
+ path: issue.path.map(String).join('.') || '(root)',
21
+ severity: 'error',
22
+ })
23
+ }
24
+ return errors
25
+ }
26
+
27
+ const parsed = result.data
28
+ const paths = parsed.paths
29
+ if (!paths) return errors
30
+
31
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
32
+ for (const [method, operation] of Object.entries(pathItem as Record<string, unknown>)) {
33
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) continue
34
+ const op = operation as Record<string, unknown>
35
+
36
+ const opPath = `paths.${pathKey}.${method}`
37
+ const rawPaymentInfo = op['x-payment-info']
38
+ if (!rawPaymentInfo) continue
39
+
40
+ const paymentResult = PaymentInfo.safeParse(rawPaymentInfo)
41
+ if (!paymentResult.success) {
42
+ for (const issue of paymentResult.error.issues) {
43
+ errors.push({
44
+ message: issue.message,
45
+ path: `${opPath}.x-payment-info.${issue.path.map(String).join('.')}`,
46
+ severity: 'error',
47
+ })
48
+ }
49
+ continue
50
+ }
51
+
52
+ const responses = op.responses as Record<string, unknown> | undefined
53
+ if (!responses || !('402' in responses)) {
54
+ errors.push({
55
+ message: 'Operation with x-payment-info MUST have a 402 response',
56
+ path: `${opPath}.responses`,
57
+ severity: 'error',
58
+ })
59
+ }
60
+
61
+ const methodUpper = method.toUpperCase()
62
+ if (
63
+ !op.requestBody &&
64
+ (methodUpper === 'POST' || methodUpper === 'PUT' || methodUpper === 'PATCH')
65
+ ) {
66
+ errors.push({
67
+ message: 'Operation with x-payment-info has no requestBody',
68
+ path: opPath,
69
+ severity: 'warning',
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ return errors
76
+ }
@@ -0,0 +1,3 @@
1
+ export * from './Discovery.js'
2
+ export * from './OpenApi.js'
3
+ export * from './Validate.js'
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import { constantTimeEqual } from './constantTimeEqual.js'
4
4
 
@@ -1,7 +1,7 @@
1
1
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import { tempo } from 'mppx/client'
3
3
  import type { Account } from 'viem'
4
- import { describe, expectTypeOf, test } from 'vitest'
4
+ import { describe, expectTypeOf, test } from 'vp/test'
5
5
 
6
6
  import * as McpClient from './McpClient.js'
7
7
 
@@ -6,7 +6,7 @@ import { Challenge, Mcp as core_Mcp } from 'mppx'
6
6
  import { tempo as tempo_client } from 'mppx/client'
7
7
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
8
8
  import { createClient } from 'viem'
9
- import { afterEach, beforeEach, describe, expect, test } from 'vitest'
9
+ import { afterEach, beforeEach, describe, expect, test } from 'vp/test'
10
10
  import { accounts, asset, chain, http, client as testClient } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as McpServer_transport from '../server/Transport.js'
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import type { Challenge } from '../../Challenge.js'
4
4
  import type { Credential } from '../../Credential.js'
@@ -3,9 +3,9 @@ import * as http from 'node:http'
3
3
  import { Elysia } from 'elysia'
4
4
  import { Receipt } from 'mppx'
5
5
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
6
- import { Mppx } from 'mppx/elysia'
6
+ import { Mppx, discovery } from 'mppx/elysia'
7
7
  import { tempo as tempo_server } from 'mppx/server'
8
- import { describe, expect, test } from 'vitest'
8
+ import { describe, expect, test } from 'vp/test'
9
9
  import { accounts, asset, client } from '~test/tempo/viem.js'
10
10
 
11
11
  function createServer(app: Elysia<any, any, any, any, any, any, any>) {
@@ -87,4 +87,29 @@ describe('charge', () => {
87
87
 
88
88
  server.close()
89
89
  })
90
+
91
+ test('serves /openapi.json from discovery plugin', async () => {
92
+ const app = new Elysia().use(
93
+ discovery(mppx, {
94
+ info: { title: 'Elysia API', version: '1.0.0' },
95
+ routes: [{ handler: mppx.charge({ amount: '1' }), method: 'get', path: '/' }],
96
+ }),
97
+ )
98
+
99
+ const server = await createServer(app)
100
+ const response = await globalThis.fetch(`${server.url}/openapi.json`)
101
+ expect(response.status).toBe(200)
102
+ expect(response.headers.get('cache-control')).toBe('public, max-age=300')
103
+
104
+ const body = (await response.json()) as Record<string, any>
105
+ expect(body.info).toEqual({ title: 'Elysia API', version: '1.0.0' })
106
+ expect(body.paths['/'].get['x-payment-info']).toMatchObject({
107
+ amount: '1000000',
108
+ currency: asset,
109
+ intent: 'charge',
110
+ method: 'tempo',
111
+ })
112
+
113
+ server.close()
114
+ })
90
115
  })
@@ -1,5 +1,6 @@
1
- import type { Context } from 'elysia'
1
+ import { Elysia, type Context } from 'elysia'
2
2
 
3
+ import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js'
3
4
  import * as Mppx_core from '../server/Mppx.js'
4
5
  import * as Mppx_internal from './internal/mppx.js'
5
6
 
@@ -68,3 +69,36 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
68
69
  if (header) set.headers['Payment-Receipt'] = header
69
70
  }
70
71
  }
72
+
73
+ export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
74
+ path?: string
75
+ routes?: RouteConfig[]
76
+ }
77
+
78
+ const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' }
79
+
80
+ /**
81
+ * Returns an Elysia plugin that serves an OpenAPI discovery document.
82
+ */
83
+ export function discovery(
84
+ mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
85
+ config: DiscoveryConfig = {},
86
+ ) {
87
+ const mountPath = config.path ?? '/openapi.json'
88
+
89
+ const cached = JSON.stringify(
90
+ generate(mppx, {
91
+ ...(config.info ? { info: config.info } : {}),
92
+ routes: config.routes ?? [],
93
+ ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}),
94
+ }),
95
+ )
96
+
97
+ return new Elysia().get(
98
+ mountPath,
99
+ () =>
100
+ new Response(cached, {
101
+ headers: { ...discoveryHeaders, 'Content-Type': 'application/json' },
102
+ }),
103
+ )
104
+ }
@@ -1,11 +1,11 @@
1
1
  import express from 'express'
2
2
  import { Receipt } from 'mppx'
3
3
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
4
- import { Mppx, payment } from 'mppx/express'
4
+ import { Mppx, discovery, payment } from 'mppx/express'
5
5
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
6
6
  import type { Address } from 'viem'
7
7
  import { Addresses } from 'viem/tempo'
8
- import { beforeAll, describe, expect, test } from 'vitest'
8
+ import { beforeAll, describe, expect, test } from 'vp/test'
9
9
  import { deployEscrow } from '~test/tempo/session.js'
10
10
  import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
11
11
 
@@ -29,7 +29,7 @@ describe('charge', () => {
29
29
  tempo_server({
30
30
  getClient: () => client,
31
31
  currency: asset,
32
- recipient: accounts[0].address,
32
+ account: accounts[0],
33
33
  }),
34
34
  ],
35
35
  secretKey,
@@ -81,6 +81,34 @@ describe('charge', () => {
81
81
 
82
82
  server.close()
83
83
  })
84
+
85
+ test('serves /openapi.json from a handler-derived route config', async () => {
86
+ const app = express()
87
+ const pay = mppx.charge({ amount: '1' })
88
+ app.get('/', pay, (_req, res) => {
89
+ res.json({ fortune: 'You will be rich' })
90
+ })
91
+ discovery(app, mppx, {
92
+ info: { title: 'Express API', version: '1.2.3' },
93
+ routes: [{ handler: pay, method: 'get', path: '/' }],
94
+ })
95
+
96
+ const server = await createServer(app)
97
+ const response = await globalThis.fetch(`${server.url}/openapi.json`)
98
+ expect(response.status).toBe(200)
99
+ expect(response.headers.get('cache-control')).toBe('public, max-age=300')
100
+
101
+ const body = (await response.json()) as Record<string, any>
102
+ expect(body.info).toEqual({ title: 'Express API', version: '1.2.3' })
103
+ expect(body.paths['/'].get['x-payment-info']).toMatchObject({
104
+ amount: '1000000',
105
+ currency: asset,
106
+ intent: 'charge',
107
+ method: 'tempo',
108
+ })
109
+
110
+ server.close()
111
+ })
84
112
  })
85
113
 
86
114
  describe('session', () => {
@@ -97,7 +125,7 @@ describe('session', () => {
97
125
  methods: [
98
126
  tempo_server.session({
99
127
  getClient: () => client,
100
- recipient: accounts[0].address,
128
+ account: accounts[0],
101
129
  currency: asset,
102
130
  escrowContract,
103
131
  }),
@@ -123,10 +151,10 @@ describe('session', () => {
123
151
  methods: [
124
152
  tempo_server.session({
125
153
  getClient: () => client,
126
- recipient: accounts[0].address,
154
+ account: accounts[0],
127
155
  currency: asset,
128
156
  escrowContract,
129
- feePayer: accounts[0],
157
+ feePayer: true,
130
158
  }),
131
159
  ],
132
160
  secretKey,
@@ -165,7 +193,7 @@ describe('payment', () => {
165
193
  tempo_server({
166
194
  getClient: () => client,
167
195
  currency: asset,
168
- recipient: accounts[0].address,
196
+ account: accounts[0],
169
197
  }),
170
198
  ],
171
199
  secretKey,
@@ -1,10 +1,12 @@
1
1
  import type {
2
+ Express,
2
3
  Request as ExpressRequest,
3
4
  Response as ExpressResponse,
4
5
  NextFunction,
5
6
  RequestHandler,
6
7
  } from 'express'
7
8
 
9
+ import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js'
8
10
  import * as Mppx_core from '../server/Mppx.js'
9
11
  import * as Mppx_internal from './internal/mppx.js'
10
12
 
@@ -84,3 +86,35 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
84
86
  next()
85
87
  }
86
88
  }
89
+
90
+ export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
91
+ path?: string
92
+ routes?: RouteConfig[]
93
+ }
94
+
95
+ const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' }
96
+
97
+ /**
98
+ * Mounts a `GET /openapi.json` route that serves an OpenAPI discovery document.
99
+ */
100
+ export function discovery(
101
+ app: Express,
102
+ mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
103
+ config: DiscoveryConfig = {},
104
+ ): void {
105
+ const mountPath = config.path ?? '/openapi.json'
106
+
107
+ const cached = JSON.stringify(
108
+ generate(mppx, {
109
+ ...(config.info ? { info: config.info } : {}),
110
+ routes: config.routes ?? [],
111
+ ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}),
112
+ }),
113
+ )
114
+
115
+ app.get(mountPath, (_req: ExpressRequest, res: ExpressResponse) => {
116
+ res.setHeader('Cache-Control', discoveryHeaders['Cache-Control'])
117
+ res.setHeader('Content-Type', 'application/json')
118
+ res.end(cached)
119
+ })
120
+ }
@@ -2,11 +2,11 @@ import { serve } from '@hono/node-server'
2
2
  import { Hono } from 'hono'
3
3
  import { Receipt } from 'mppx'
4
4
  import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
5
- import { Mppx } from 'mppx/hono'
5
+ import { Mppx, discovery } from 'mppx/hono'
6
6
  import { tempo as tempo_server } from 'mppx/server'
7
7
  import type { Address } from 'viem'
8
8
  import { Addresses } from 'viem/tempo'
9
- import { beforeAll, describe, expect, test } from 'vitest'
9
+ import { beforeAll, describe, expect, test } from 'vp/test'
10
10
  import { deployEscrow } from '~test/tempo/session.js'
11
11
  import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
12
12
 
@@ -29,7 +29,7 @@ describe('charge', () => {
29
29
  tempo_server.charge({
30
30
  getClient: () => client,
31
31
  currency: asset,
32
- recipient: accounts[0].address,
32
+ account: accounts[0],
33
33
  }),
34
34
  ],
35
35
  secretKey,
@@ -74,6 +74,28 @@ describe('charge', () => {
74
74
 
75
75
  server.close()
76
76
  })
77
+
78
+ test('serves /openapi.json via auto discovery', async () => {
79
+ const app = new Hono()
80
+ app.get('/', mppx.charge({ amount: '1' }), (c) => c.json({ fortune: 'You will be rich' }))
81
+ discovery(app, mppx, { auto: true, info: { title: 'Auto API', version: '2.0.0' } })
82
+
83
+ const server = await createServer(app)
84
+ const response = await globalThis.fetch(`${server.url}/openapi.json`)
85
+ expect(response.status).toBe(200)
86
+ expect(response.headers.get('cache-control')).toBe('public, max-age=300')
87
+
88
+ const body = (await response.json()) as Record<string, any>
89
+ expect(body.info).toEqual({ title: 'Auto API', version: '2.0.0' })
90
+ expect(body.paths['/'].get['x-payment-info']).toMatchObject({
91
+ amount: '1000000',
92
+ currency: asset,
93
+ intent: 'charge',
94
+ method: 'tempo',
95
+ })
96
+
97
+ server.close()
98
+ })
77
99
  })
78
100
 
79
101
  describe('session', () => {
@@ -90,7 +112,7 @@ describe('session', () => {
90
112
  methods: [
91
113
  tempo_server.session({
92
114
  getClient: () => client,
93
- recipient: accounts[0].address,
115
+ account: accounts[0],
94
116
  currency: asset,
95
117
  escrowContract,
96
118
  }),
@@ -116,10 +138,10 @@ describe('session', () => {
116
138
  methods: [
117
139
  tempo_server.session({
118
140
  getClient: () => client,
119
- recipient: accounts[0].address,
141
+ account: accounts[0],
120
142
  currency: asset,
121
143
  escrowContract,
122
- feePayer: accounts[0],
144
+ feePayer: true,
123
145
  }),
124
146
  ],
125
147
  secretKey,
@@ -1,5 +1,6 @@
1
- import type { MiddlewareHandler } from 'hono'
1
+ import type { Hono, MiddlewareHandler } from 'hono'
2
2
 
3
+ import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js'
3
4
  import * as Mppx_core from '../server/Mppx.js'
4
5
  import * as Mppx_internal from './internal/mppx.js'
5
6
 
@@ -61,3 +62,74 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
61
62
  c.res = result.withReceipt(c.res)
62
63
  }
63
64
  }
65
+
66
+ export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
67
+ auto?: boolean
68
+ path?: string
69
+ routes?: RouteConfig[]
70
+ }
71
+
72
+ const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' }
73
+
74
+ /**
75
+ * Mounts a `GET /openapi.json` route that serves an OpenAPI discovery document.
76
+ *
77
+ * When `auto` is true, routes are introspected from Hono's internal `app.routes`
78
+ * array. This is a **best-effort / experimental** convenience — `app.routes` is
79
+ * not part of Hono's stable public API and may change across versions. Prefer
80
+ * passing explicit `routes` for production use.
81
+ */
82
+ export function discovery(
83
+ app: Hono<any>,
84
+ mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
85
+ config: DiscoveryConfig = {},
86
+ ): void {
87
+ const mountPath = config.path ?? '/openapi.json'
88
+
89
+ let cached: string | undefined
90
+
91
+ app.get(mountPath, (c) => {
92
+ if (!cached) {
93
+ const routes = config.routes ?? (config.auto ? introspectRoutes(app) : [])
94
+ const doc = generate(mppx, {
95
+ ...(config.info ? { info: config.info } : {}),
96
+ routes,
97
+ ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}),
98
+ })
99
+ cached = JSON.stringify(doc)
100
+ }
101
+
102
+ c.header('Cache-Control', discoveryHeaders['Cache-Control'])
103
+ c.header('Content-Type', 'application/json')
104
+ return c.body(cached)
105
+ })
106
+ }
107
+
108
+ function introspectRoutes(app: Hono<any>): RouteConfig[] {
109
+ const routes: RouteConfig[] = []
110
+ const appRoutes = (app as any).routes as
111
+ | { handler: any; method: string; path: string }[]
112
+ | undefined
113
+
114
+ if (!appRoutes) return routes
115
+
116
+ const seen = new Set<string>()
117
+
118
+ for (const route of appRoutes) {
119
+ const internal = (route.handler as { _internal?: Record<string, unknown> } | undefined)
120
+ ?._internal
121
+ if (!internal) continue
122
+
123
+ const key = `${route.method}:${route.path}:${internal.name}/${internal.intent}`
124
+ if (seen.has(key)) continue
125
+ seen.add(key)
126
+
127
+ routes.push({
128
+ handler: route.handler,
129
+ method: route.method,
130
+ path: route.path,
131
+ })
132
+ }
133
+
134
+ return routes
135
+ }
@@ -1,6 +1,6 @@
1
1
  import { Challenge, Credential, Method, z } from 'mppx'
2
2
  import { Mppx } from 'mppx/server'
3
- import { describe, expect, test } from 'vitest'
3
+ import { describe, expect, test } from 'vp/test'
4
4
 
5
5
  import { wrap } from './mppx.js'
6
6