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.
- package/CHANGELOG.md +17 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +155 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/discovery/Discovery.d.ts +146 -0
- package/dist/discovery/Discovery.d.ts.map +1 -0
- package/dist/discovery/Discovery.js +60 -0
- package/dist/discovery/Discovery.js.map +1 -0
- package/dist/discovery/OpenApi.d.ts +61 -0
- package/dist/discovery/OpenApi.d.ts.map +1 -0
- package/dist/discovery/OpenApi.js +139 -0
- package/dist/discovery/OpenApi.js.map +1 -0
- package/dist/discovery/Validate.d.ts +10 -0
- package/dist/discovery/Validate.d.ts.map +1 -0
- package/dist/discovery/Validate.js +63 -0
- package/dist/discovery/Validate.js.map +1 -0
- package/dist/discovery/index.d.ts +4 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +4 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/middlewares/elysia.d.ts +52 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +17 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts +13 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +18 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts +19 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +51 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +4 -2
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +10 -3
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts +11 -0
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +15 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts +6 -0
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +56 -80
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts +16 -23
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +19 -83
- package/dist/proxy/Service.js.map +1 -1
- package/dist/proxy/internal/Route.js +1 -1
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/proxy/services/anthropic.d.ts.map +1 -1
- package/dist/proxy/services/anthropic.js +5 -0
- package/dist/proxy/services/anthropic.js.map +1 -1
- package/dist/proxy/services/openai.d.ts.map +1 -1
- package/dist/proxy/services/openai.js +6 -3
- package/dist/proxy/services/openai.js.map +1 -1
- package/dist/proxy/services/stripe.d.ts.map +1 -1
- package/dist/proxy/services/stripe.js +6 -3
- package/dist/proxy/services/stripe.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -5
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +8 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.js +1 -1
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +6 -1
- package/src/BodyDigest.test.ts +1 -1
- package/src/Challenge.fuzz.test.ts +121 -0
- package/src/Challenge.test-d.ts +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Credential.fuzz.test.ts +62 -0
- package/src/Credential.test.ts +1 -1
- package/src/Errors.test.ts +1 -1
- package/src/Expires.test.ts +1 -1
- package/src/Method.test.ts +1 -1
- package/src/PaymentRequest.test.ts +1 -1
- package/src/Receipt.test.ts +1 -1
- package/src/Store.test-d.ts +1 -1
- package/src/Store.test.ts +1 -1
- package/src/cli/cli.test.ts +212 -1
- package/src/cli/cli.ts +162 -0
- package/src/client/Mppx.test-d.ts +1 -1
- package/src/client/Mppx.test.ts +1 -1
- package/src/client/Transport.test.ts +1 -1
- package/src/client/internal/Fetch.browser.test.ts +1 -1
- package/src/client/internal/Fetch.test-d.ts +1 -1
- package/src/client/internal/Fetch.test.ts +2 -1
- package/src/discovery/Discovery.test.ts +152 -0
- package/src/discovery/Discovery.ts +72 -0
- package/src/discovery/OpenApi.test.ts +425 -0
- package/src/discovery/OpenApi.ts +224 -0
- package/src/discovery/Validate.test.ts +188 -0
- package/src/discovery/Validate.ts +76 -0
- package/src/discovery/index.ts +3 -0
- package/src/internal/constantTimeEqual.test.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test.ts +1 -1
- package/src/mcp-sdk/server/Transport.test.ts +1 -1
- package/src/middlewares/elysia.test.ts +27 -2
- package/src/middlewares/elysia.ts +35 -1
- package/src/middlewares/express.test.ts +35 -7
- package/src/middlewares/express.ts +34 -0
- package/src/middlewares/hono.test.ts +28 -6
- package/src/middlewares/hono.ts +73 -1
- package/src/middlewares/internal/mppx.test.ts +1 -1
- package/src/middlewares/internal/mppx.ts +14 -6
- package/src/middlewares/nextjs.test.ts +31 -6
- package/src/middlewares/nextjs.ts +28 -0
- package/src/proxy/Proxy.test.ts +54 -270
- package/src/proxy/Proxy.ts +71 -93
- package/src/proxy/Service.test.ts +23 -1
- package/src/proxy/Service.ts +40 -86
- package/src/proxy/internal/Headers.test.ts +1 -1
- package/src/proxy/internal/Route.test.ts +9 -1
- package/src/proxy/internal/Route.ts +1 -1
- package/src/proxy/services/anthropic.test.ts +132 -0
- package/src/proxy/services/anthropic.ts +5 -0
- package/src/proxy/services/openai.test.ts +1 -1
- package/src/proxy/services/openai.ts +6 -4
- package/src/proxy/services/stripe.test.ts +132 -0
- package/src/proxy/services/stripe.ts +6 -4
- package/src/server/Mppx.test-d.ts +1 -1
- package/src/server/Mppx.test.ts +2 -1
- package/src/server/NodeListener.test.ts +1 -1
- package/src/server/Request.test.ts +1 -1
- package/src/server/Response.test.ts +1 -1
- package/src/server/Transport.test.ts +1 -1
- package/src/stripe/Charge.integration.test.ts +1 -1
- package/src/stripe/Methods.test.ts +1 -1
- package/src/stripe/client/Charge.test.ts +1 -1
- package/src/stripe/server/Charge.test.ts +1 -1
- package/src/tempo/Attribution.test.ts +1 -1
- package/src/tempo/Methods.test.ts +1 -1
- package/src/tempo/client/ChannelOps.test.ts +6 -3
- package/src/tempo/client/Session.test.ts +5 -2
- package/src/tempo/client/SessionManager.test.ts +1 -1
- package/src/tempo/internal/auto-swap.test.ts +1 -1
- package/src/tempo/internal/defaults.test.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +1 -1
- package/src/tempo/server/Charge.test.ts +1 -1
- package/src/tempo/server/Session.test.ts +87 -37
- package/src/tempo/server/Session.ts +25 -8
- package/src/tempo/server/Sse.test.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +24 -1
- package/src/tempo/server/internal/transport.ts +11 -0
- package/src/tempo/session/Chain.test.ts +5 -2
- package/src/tempo/session/Chain.ts +1 -1
- package/src/tempo/session/Channel.test.ts +1 -1
- package/src/tempo/session/ChannelStore.test.ts +1 -1
- package/src/tempo/session/Receipt.test.ts +1 -1
- package/src/tempo/session/Sse.fuzz.test.ts +138 -0
- package/src/tempo/session/Sse.test.ts +1 -1
- package/src/tempo/session/Voucher.test.ts +1 -1
- package/src/viem/Account.test.ts +1 -1
- package/src/viem/Client.test.ts +1 -1
- 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
|
+
}
|
|
@@ -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 '
|
|
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 '
|
|
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'
|
|
@@ -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 '
|
|
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
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
account: accounts[0],
|
|
127
155
|
currency: asset,
|
|
128
156
|
escrowContract,
|
|
129
|
-
feePayer:
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
account: accounts[0],
|
|
120
142
|
currency: asset,
|
|
121
143
|
escrowContract,
|
|
122
|
-
feePayer:
|
|
144
|
+
feePayer: true,
|
|
123
145
|
}),
|
|
124
146
|
],
|
|
125
147
|
secretKey,
|
package/src/middlewares/hono.ts
CHANGED
|
@@ -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
|
+
}
|