mppx 0.4.9 → 0.4.11
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 +25 -1
- 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/stripe/internal/types.d.ts +3 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +9 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +25 -8
- 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/internal/types.ts +5 -1
- package/src/stripe/server/Charge.test.ts +53 -2
- package/src/stripe/server/Charge.ts +12 -4
- 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 +116 -37
- package/src/tempo/server/Session.ts +32 -11
- 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,121 @@
|
|
|
1
|
+
import * as fc from 'fast-check'
|
|
2
|
+
import { Challenge } from 'mppx'
|
|
3
|
+
import { describe, expect, test } from 'vp/test'
|
|
4
|
+
|
|
5
|
+
describe('parseAuthParams robustness', () => {
|
|
6
|
+
test('deserialize never throws unhandled exception on arbitrary input', () => {
|
|
7
|
+
fc.assert(
|
|
8
|
+
fc.property(fc.string(), (input) => {
|
|
9
|
+
try {
|
|
10
|
+
Challenge.deserialize(input)
|
|
11
|
+
} catch (e) {
|
|
12
|
+
if (!(e instanceof Error)) throw e
|
|
13
|
+
if (e instanceof TypeError || e instanceof RangeError) throw e
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
{ numRuns: 10_000 },
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('adversarial header strings', () => {
|
|
22
|
+
const adversarialHeader = fc.oneof(
|
|
23
|
+
// Deeply nested quotes
|
|
24
|
+
fc.string().map((s) => `Payment ${s}`),
|
|
25
|
+
// Unterminated quotes
|
|
26
|
+
fc.string().map((s) => `Payment id="${s}`),
|
|
27
|
+
// Escaped characters at boundary
|
|
28
|
+
fc.string().map((s) => `Payment id="\\${s}"`),
|
|
29
|
+
// Many commas
|
|
30
|
+
fc.nat({ max: 100 }).map((n) => `Payment ${',,,,'.repeat(n)}`),
|
|
31
|
+
// Very long keys
|
|
32
|
+
fc
|
|
33
|
+
.string({ minLength: 1000, maxLength: 5000 })
|
|
34
|
+
.map((s) => `Payment ${s.replace(/[^a-z]/g, 'a')}="val"`),
|
|
35
|
+
// NUL and control characters
|
|
36
|
+
fc
|
|
37
|
+
.uint8Array({ minLength: 1, maxLength: 200 })
|
|
38
|
+
.map((arr) => `Payment id="${String.fromCharCode(...arr)}"`),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
test('adversarial headers never cause unhandled exceptions', () => {
|
|
42
|
+
fc.assert(
|
|
43
|
+
fc.property(adversarialHeader, (input) => {
|
|
44
|
+
try {
|
|
45
|
+
Challenge.deserialize(input)
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (!(e instanceof Error)) throw e
|
|
48
|
+
if (e instanceof TypeError || e instanceof RangeError) throw e
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
{ numRuns: 10_000 },
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('serialize/deserialize roundtrip', () => {
|
|
57
|
+
const challengeArb = fc.record({
|
|
58
|
+
id: fc.string({ minLength: 1 }).filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
|
|
59
|
+
realm: fc.string({ minLength: 1 }).filter((s) => /^[A-Za-z0-9._-]+$/.test(s)),
|
|
60
|
+
method: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9:_-]*$/.test(s)),
|
|
61
|
+
intent: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9_-]*$/.test(s)),
|
|
62
|
+
request: fc.dictionary(
|
|
63
|
+
fc
|
|
64
|
+
.string({ minLength: 1 })
|
|
65
|
+
.filter((s) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s) && s !== '__proto__'),
|
|
66
|
+
fc.oneof(fc.string(), fc.integer().map(String), fc.boolean().map(String)),
|
|
67
|
+
),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('serialize then deserialize produces equivalent challenge', () => {
|
|
71
|
+
fc.assert(
|
|
72
|
+
fc.property(challengeArb, (input) => {
|
|
73
|
+
const serialized = Challenge.serialize(input as Challenge.Challenge)
|
|
74
|
+
const deserialized = Challenge.deserialize(serialized)
|
|
75
|
+
|
|
76
|
+
expect(deserialized.id).toBe(input.id)
|
|
77
|
+
expect(deserialized.realm).toBe(input.realm)
|
|
78
|
+
expect(deserialized.method).toBe(input.method)
|
|
79
|
+
expect(deserialized.intent).toBe(input.intent)
|
|
80
|
+
expect(deserialized.request).toEqual(input.request)
|
|
81
|
+
}),
|
|
82
|
+
{ numRuns: 1_000 },
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('deserializeList roundtrip', () => {
|
|
88
|
+
const challengeArb = fc.record({
|
|
89
|
+
id: fc.string({ minLength: 1 }).filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
|
|
90
|
+
realm: fc.string({ minLength: 1 }).filter((s) => /^[A-Za-z0-9._-]+$/.test(s)),
|
|
91
|
+
method: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9:_-]*$/.test(s)),
|
|
92
|
+
intent: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9_-]*$/.test(s)),
|
|
93
|
+
request: fc.dictionary(
|
|
94
|
+
fc
|
|
95
|
+
.string({ minLength: 1 })
|
|
96
|
+
.filter((s) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s) && s !== '__proto__'),
|
|
97
|
+
fc.oneof(fc.string(), fc.integer().map(String), fc.boolean().map(String)),
|
|
98
|
+
),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('serialize multiple then deserializeList returns all challenges', () => {
|
|
102
|
+
fc.assert(
|
|
103
|
+
fc.property(fc.array(challengeArb, { minLength: 1, maxLength: 3 }), (challenges) => {
|
|
104
|
+
const header = challenges
|
|
105
|
+
.map((c) => Challenge.serialize(c as Challenge.Challenge))
|
|
106
|
+
.join(', ')
|
|
107
|
+
const result = Challenge.deserializeList(header)
|
|
108
|
+
|
|
109
|
+
expect(result).toHaveLength(challenges.length)
|
|
110
|
+
for (let i = 0; i < challenges.length; i++) {
|
|
111
|
+
expect(result[i]!.id).toBe(challenges[i]!.id)
|
|
112
|
+
expect(result[i]!.realm).toBe(challenges[i]!.realm)
|
|
113
|
+
expect(result[i]!.method).toBe(challenges[i]!.method)
|
|
114
|
+
expect(result[i]!.intent).toBe(challenges[i]!.intent)
|
|
115
|
+
expect(result[i]!.request).toEqual(challenges[i]!.request)
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
{ numRuns: 1_000 },
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
package/src/Challenge.test-d.ts
CHANGED
package/src/Challenge.test.ts
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fc from 'fast-check'
|
|
2
|
+
import { Challenge, Credential, Receipt } from 'mppx'
|
|
3
|
+
import { describe, expect, test } from 'vp/test'
|
|
4
|
+
|
|
5
|
+
describe('Credential', () => {
|
|
6
|
+
test('serialize → deserialize roundtrip', () => {
|
|
7
|
+
const credentialArb = fc.record({
|
|
8
|
+
challenge: fc.record({
|
|
9
|
+
id: fc.string({ minLength: 1 }).filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
|
|
10
|
+
realm: fc.string({ minLength: 1 }).filter((s) => /^[^\x00-\x1f]+$/.test(s)),
|
|
11
|
+
method: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9:_-]*$/.test(s)),
|
|
12
|
+
intent: fc.string({ minLength: 1 }).filter((s) => /^[a-z][a-z0-9_-]*$/.test(s)),
|
|
13
|
+
request: fc.constant({ amount: '1000' }),
|
|
14
|
+
}),
|
|
15
|
+
payload: fc.dictionary(
|
|
16
|
+
fc.string({ minLength: 1 }).filter((s) => /^[a-zA-Z_]+$/.test(s)),
|
|
17
|
+
fc.string(),
|
|
18
|
+
),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
fc.assert(
|
|
22
|
+
fc.property(credentialArb, (input) => {
|
|
23
|
+
const challenge = Challenge.from(input.challenge)
|
|
24
|
+
const credential = Credential.from({ challenge, payload: input.payload })
|
|
25
|
+
const serialized = Credential.serialize(credential)
|
|
26
|
+
const deserialized = Credential.deserialize(serialized)
|
|
27
|
+
|
|
28
|
+
expect(deserialized.challenge.id).toBe(challenge.id)
|
|
29
|
+
expect(deserialized.challenge.realm).toBe(challenge.realm)
|
|
30
|
+
expect(deserialized.challenge.method).toBe(challenge.method)
|
|
31
|
+
expect(deserialized.challenge.intent).toBe(challenge.intent)
|
|
32
|
+
expect(deserialized.challenge.request).toEqual(challenge.request)
|
|
33
|
+
expect(deserialized.payload).toEqual(input.payload)
|
|
34
|
+
}),
|
|
35
|
+
{ numRuns: 1_000 },
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('Receipt', () => {
|
|
41
|
+
test('serialize → deserialize roundtrip', () => {
|
|
42
|
+
const receiptArb = fc.record({
|
|
43
|
+
method: fc.string({ minLength: 1 }).filter((s) => /^[a-z]+$/.test(s)),
|
|
44
|
+
reference: fc.string({ minLength: 1 }),
|
|
45
|
+
status: fc.constant('success' as const),
|
|
46
|
+
timestamp: fc
|
|
47
|
+
.integer({ min: new Date('2020-01-01').getTime(), max: new Date('2030-01-01').getTime() })
|
|
48
|
+
.map((t) => new Date(t).toISOString()),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
fc.assert(
|
|
52
|
+
fc.property(receiptArb, (input) => {
|
|
53
|
+
const receipt = Receipt.from(input)
|
|
54
|
+
const serialized = Receipt.serialize(receipt)
|
|
55
|
+
const deserialized = Receipt.deserialize(serialized)
|
|
56
|
+
|
|
57
|
+
expect(deserialized).toEqual(receipt)
|
|
58
|
+
}),
|
|
59
|
+
{ numRuns: 1_000 },
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/Credential.test.ts
CHANGED
package/src/Errors.test.ts
CHANGED
package/src/Expires.test.ts
CHANGED
package/src/Method.test.ts
CHANGED
package/src/Receipt.test.ts
CHANGED
package/src/Store.test-d.ts
CHANGED
package/src/Store.test.ts
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as path from 'node:path'
|
|
|
6
6
|
import { parseUnits } from 'viem'
|
|
7
7
|
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
8
8
|
import { Addresses } from 'viem/tempo'
|
|
9
|
-
import { afterAll, describe, expect, test } from '
|
|
9
|
+
import { afterAll, describe, expect, test } from 'vp/test'
|
|
10
10
|
import * as Http from '~test/Http.js'
|
|
11
11
|
import { rpcUrl } from '~test/tempo/prool.js'
|
|
12
12
|
import { deployEscrow } from '~test/tempo/session.js'
|
|
@@ -76,6 +76,217 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
|
|
|
76
76
|
return { output, stderr, exitCode }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
describe('discover validate', () => {
|
|
80
|
+
test('validates a local discovery document', async () => {
|
|
81
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
|
|
82
|
+
const file = path.join(dir, 'openapi.json')
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
file,
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
87
|
+
openapi: '3.1.0',
|
|
88
|
+
paths: {
|
|
89
|
+
'/search': {
|
|
90
|
+
post: {
|
|
91
|
+
'x-payment-info': {
|
|
92
|
+
amount: '100',
|
|
93
|
+
intent: 'charge',
|
|
94
|
+
method: 'tempo',
|
|
95
|
+
},
|
|
96
|
+
requestBody: {
|
|
97
|
+
content: { 'application/json': { schema: { type: 'object' } } },
|
|
98
|
+
},
|
|
99
|
+
responses: {
|
|
100
|
+
'200': { description: 'OK' },
|
|
101
|
+
'402': { description: 'Payment Required' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const { output, exitCode } = await serve(['discover', 'validate', file])
|
|
110
|
+
expect(exitCode).toBeUndefined()
|
|
111
|
+
expect(output).toContain('Discovery document is valid.')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('returns non-zero for invalid discovery documents', async () => {
|
|
115
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
|
|
116
|
+
const file = path.join(dir, 'openapi.json')
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
file,
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
121
|
+
openapi: '3.1.0',
|
|
122
|
+
paths: {
|
|
123
|
+
'/search': {
|
|
124
|
+
post: {
|
|
125
|
+
'x-payment-info': {
|
|
126
|
+
amount: '100',
|
|
127
|
+
intent: 'charge',
|
|
128
|
+
method: 'tempo',
|
|
129
|
+
},
|
|
130
|
+
responses: {
|
|
131
|
+
'200': { description: 'OK' },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const { output, exitCode } = await serve(['discover', 'validate', file])
|
|
140
|
+
expect(exitCode).toBe(1)
|
|
141
|
+
expect(output).toContain('[error]')
|
|
142
|
+
expect(output).toContain('402')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test(
|
|
146
|
+
'validates remote discovery documents and reports warnings',
|
|
147
|
+
{ timeout: 20_000 },
|
|
148
|
+
async () => {
|
|
149
|
+
const body = JSON.stringify({
|
|
150
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
151
|
+
openapi: '3.1.0',
|
|
152
|
+
paths: {
|
|
153
|
+
'/search': {
|
|
154
|
+
post: {
|
|
155
|
+
'x-payment-info': {
|
|
156
|
+
amount: '100',
|
|
157
|
+
intent: 'charge',
|
|
158
|
+
method: 'tempo',
|
|
159
|
+
},
|
|
160
|
+
responses: {
|
|
161
|
+
'200': { description: 'OK' },
|
|
162
|
+
'402': { description: 'Payment Required' },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
const server = await Http.createServer((_req, res) => {
|
|
169
|
+
res.setHeader('Content-Type', 'application/json')
|
|
170
|
+
res.end(body)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const { output, exitCode } = await serve(['discover', 'validate', server.url])
|
|
175
|
+
expect(exitCode).toBeUndefined()
|
|
176
|
+
expect(output).toContain('[warning]')
|
|
177
|
+
expect(output).toContain('requestBody')
|
|
178
|
+
expect(output).toContain('valid with 1 warning')
|
|
179
|
+
} finally {
|
|
180
|
+
server.close()
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
test(
|
|
186
|
+
'rejects oversized discovery documents via content-length',
|
|
187
|
+
{ timeout: 20_000 },
|
|
188
|
+
async () => {
|
|
189
|
+
const server = await Http.createServer((_req, res) => {
|
|
190
|
+
res.setHeader('Content-Type', 'application/json')
|
|
191
|
+
res.setHeader('Content-Length', String(11 * 1024 * 1024))
|
|
192
|
+
res.end('{}')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const { exitCode, output } = await serve(['discover', 'validate', server.url])
|
|
197
|
+
expect(exitCode).toBe(1)
|
|
198
|
+
expect(output).toContain('10 MB')
|
|
199
|
+
} finally {
|
|
200
|
+
server.close()
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('discover generate', () => {
|
|
207
|
+
test('generates from a pre-built OpenAPI document module', async () => {
|
|
208
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
|
|
209
|
+
const mod = path.join(dir, 'doc.mjs')
|
|
210
|
+
fs.writeFileSync(
|
|
211
|
+
mod,
|
|
212
|
+
`export default ${JSON.stringify({
|
|
213
|
+
openapi: '3.1.0',
|
|
214
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
215
|
+
paths: {
|
|
216
|
+
'/pay': {
|
|
217
|
+
post: {
|
|
218
|
+
'x-payment-info': { amount: '100', intent: 'charge', method: 'tempo' },
|
|
219
|
+
responses: {
|
|
220
|
+
'200': { description: 'OK' },
|
|
221
|
+
'402': { description: 'Payment Required' },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
})}`,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const { output, exitCode } = await serve(['discover', 'generate', mod])
|
|
230
|
+
expect(exitCode).toBeUndefined()
|
|
231
|
+
const doc = JSON.parse(output)
|
|
232
|
+
expect(doc.openapi).toBe('3.1.0')
|
|
233
|
+
expect(doc.paths['/pay'].post['x-payment-info'].amount).toBe('100')
|
|
234
|
+
|
|
235
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('writes to file with --output', async () => {
|
|
239
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
|
|
240
|
+
const mod = path.join(dir, 'doc.mjs')
|
|
241
|
+
const outFile = path.join(dir, 'openapi.json')
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
mod,
|
|
244
|
+
`export default ${JSON.stringify({
|
|
245
|
+
openapi: '3.1.0',
|
|
246
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
247
|
+
paths: {},
|
|
248
|
+
})}`,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
const { output, stderr, exitCode } = await serve([
|
|
252
|
+
'discover',
|
|
253
|
+
'generate',
|
|
254
|
+
mod,
|
|
255
|
+
'--output',
|
|
256
|
+
outFile,
|
|
257
|
+
])
|
|
258
|
+
expect(exitCode).toBeUndefined()
|
|
259
|
+
expect(output).toBe('')
|
|
260
|
+
expect(stderr).toContain(outFile)
|
|
261
|
+
const written = JSON.parse(fs.readFileSync(outFile, 'utf-8'))
|
|
262
|
+
expect(written.openapi).toBe('3.1.0')
|
|
263
|
+
|
|
264
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('errors when module not found', async () => {
|
|
268
|
+
const { output, exitCode } = await serve([
|
|
269
|
+
'discover',
|
|
270
|
+
'generate',
|
|
271
|
+
'/tmp/nonexistent-mppx-module.mjs',
|
|
272
|
+
])
|
|
273
|
+
expect(exitCode).toBe(1)
|
|
274
|
+
expect(output).toContain('MODULE_NOT_FOUND')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('errors when module has no mppx or openapi export', async () => {
|
|
278
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
|
|
279
|
+
const mod = path.join(dir, 'bad.mjs')
|
|
280
|
+
fs.writeFileSync(mod, 'export default { foo: "bar" }')
|
|
281
|
+
|
|
282
|
+
const { output, exitCode } = await serve(['discover', 'generate', mod])
|
|
283
|
+
expect(exitCode).toBe(1)
|
|
284
|
+
expect(output).toContain('INVALID_MODULE')
|
|
285
|
+
|
|
286
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
79
290
|
describe('basic charge (examples/basic)', () => {
|
|
80
291
|
test('happy path: makes payment and receives response', { timeout: 120_000 }, async () => {
|
|
81
292
|
const { Actions } = await import('viem/tempo')
|
package/src/cli/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { tempo as tempoMainnet } from 'viem/chains'
|
|
|
11
11
|
import * as Challenge from '../Challenge.js'
|
|
12
12
|
import { normalizeHeaders } from '../client/internal/Fetch.js'
|
|
13
13
|
import * as Mppx from '../client/Mppx.js'
|
|
14
|
+
import { validate as validateDiscovery } from '../discovery/Validate.js'
|
|
14
15
|
import { createDefaultStore, createKeychain, resolveAccountName } from './account.js'
|
|
15
16
|
import { loadConfig, resolvePlugin } from './internal.js'
|
|
16
17
|
import type { Plugin } from './plugins/plugin.js'
|
|
@@ -915,7 +916,168 @@ export default defineConfig({
|
|
|
915
916
|
},
|
|
916
917
|
})
|
|
917
918
|
|
|
919
|
+
const discover = Cli.create('discover', {
|
|
920
|
+
description: 'Discovery tooling',
|
|
921
|
+
})
|
|
922
|
+
.command('generate', {
|
|
923
|
+
description: 'Generate a static OpenAPI discovery document from a module',
|
|
924
|
+
args: z.object({
|
|
925
|
+
module: z.string().describe('Path to a module that default-exports a discovery config'),
|
|
926
|
+
}),
|
|
927
|
+
options: z.object({
|
|
928
|
+
output: z.string().optional().describe('Write output to a file instead of stdout'),
|
|
929
|
+
}),
|
|
930
|
+
alias: { output: 'o' },
|
|
931
|
+
async run(c) {
|
|
932
|
+
const modulePath = path.resolve(c.args.module)
|
|
933
|
+
if (!fs.existsSync(modulePath)) {
|
|
934
|
+
return c.error({
|
|
935
|
+
code: 'MODULE_NOT_FOUND',
|
|
936
|
+
message: `Module not found: ${modulePath}`,
|
|
937
|
+
exitCode: 1,
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let mod: Record<string, unknown>
|
|
942
|
+
try {
|
|
943
|
+
mod = await import(modulePath)
|
|
944
|
+
} catch (error) {
|
|
945
|
+
return c.error({
|
|
946
|
+
code: 'MODULE_IMPORT_FAILED',
|
|
947
|
+
message: `Failed to import module: ${(error as Error).message}`,
|
|
948
|
+
exitCode: 1,
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const exported = (mod.default ?? mod) as Record<string, unknown>
|
|
953
|
+
|
|
954
|
+
// If the export is already a plain OpenAPI doc (has `openapi` key), use it directly.
|
|
955
|
+
// Otherwise, expect { mppx, ...GenerateConfig } and call generate().
|
|
956
|
+
let doc: Record<string, unknown>
|
|
957
|
+
if (typeof exported.openapi === 'string') {
|
|
958
|
+
doc = exported
|
|
959
|
+
} else {
|
|
960
|
+
const { generate } = await import('../discovery/OpenApi.js')
|
|
961
|
+
const mppx = exported.mppx as { methods: readonly any[]; realm: string }
|
|
962
|
+
if (!mppx) {
|
|
963
|
+
return c.error({
|
|
964
|
+
code: 'INVALID_MODULE',
|
|
965
|
+
message:
|
|
966
|
+
'Module must default-export an OpenAPI document (with `openapi` key) or an object with `mppx` (server instance) and `routes`.',
|
|
967
|
+
exitCode: 1,
|
|
968
|
+
})
|
|
969
|
+
}
|
|
970
|
+
doc = generate(mppx, exported as any)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const json = JSON.stringify(doc, null, 2)
|
|
974
|
+
if (c.options.output) {
|
|
975
|
+
const outPath = path.resolve(c.options.output)
|
|
976
|
+
fs.writeFileSync(outPath, `${json}\n`)
|
|
977
|
+
process.stderr.write(`Wrote ${outPath}\n`)
|
|
978
|
+
} else {
|
|
979
|
+
console.log(json)
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
})
|
|
983
|
+
.command('validate', {
|
|
984
|
+
description: 'Validate an OpenAPI discovery document from a file or URL',
|
|
985
|
+
args: z.object({
|
|
986
|
+
input: z.string().describe('Path or URL to a discovery document'),
|
|
987
|
+
}),
|
|
988
|
+
async run(c) {
|
|
989
|
+
const input = c.args.input
|
|
990
|
+
let raw: string
|
|
991
|
+
if (/^https?:\/\//.test(input)) {
|
|
992
|
+
const controller = new AbortController()
|
|
993
|
+
const timeout = setTimeout(() => controller.abort(), 30_000)
|
|
994
|
+
let response: Response
|
|
995
|
+
try {
|
|
996
|
+
response = await globalThis.fetch(input, { signal: controller.signal })
|
|
997
|
+
} catch (error) {
|
|
998
|
+
clearTimeout(timeout)
|
|
999
|
+
const msg =
|
|
1000
|
+
error instanceof DOMException && error.name === 'AbortError'
|
|
1001
|
+
? 'Request timed out after 30s'
|
|
1002
|
+
: (error as Error).message
|
|
1003
|
+
return c.error({
|
|
1004
|
+
code: 'DISCOVERY_FETCH_FAILED',
|
|
1005
|
+
message: `Failed to fetch discovery document: ${msg}`,
|
|
1006
|
+
exitCode: 1,
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
clearTimeout(timeout)
|
|
1010
|
+
if (!response.ok) {
|
|
1011
|
+
return c.error({
|
|
1012
|
+
code: 'DISCOVERY_FETCH_FAILED',
|
|
1013
|
+
message: `Failed to fetch discovery document: HTTP ${response.status}`,
|
|
1014
|
+
exitCode: 1,
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
const maxSize = 10 * 1024 * 1024 // 10 MB
|
|
1018
|
+
const contentLength = response.headers.get('content-length')
|
|
1019
|
+
if (contentLength && Number(contentLength) > maxSize) {
|
|
1020
|
+
return c.error({
|
|
1021
|
+
code: 'DISCOVERY_TOO_LARGE',
|
|
1022
|
+
message: `Discovery document exceeds 10 MB limit`,
|
|
1023
|
+
exitCode: 1,
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
raw = await response.text()
|
|
1027
|
+
if (raw.length > maxSize) {
|
|
1028
|
+
return c.error({
|
|
1029
|
+
code: 'DISCOVERY_TOO_LARGE',
|
|
1030
|
+
message: `Discovery document exceeds 10 MB limit`,
|
|
1031
|
+
exitCode: 1,
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
const resolved = path.resolve(input)
|
|
1036
|
+
if (!fs.existsSync(resolved)) {
|
|
1037
|
+
return c.error({
|
|
1038
|
+
code: 'DISCOVERY_NOT_FOUND',
|
|
1039
|
+
message: `Discovery document not found: ${resolved}`,
|
|
1040
|
+
exitCode: 1,
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
raw = fs.readFileSync(resolved, 'utf-8')
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
let doc: unknown
|
|
1047
|
+
try {
|
|
1048
|
+
doc = JSON.parse(raw)
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
return c.error({
|
|
1051
|
+
code: 'DISCOVERY_INVALID_JSON',
|
|
1052
|
+
message: `Invalid discovery JSON: ${(error as Error).message}`,
|
|
1053
|
+
exitCode: 1,
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const issues = validateDiscovery(doc)
|
|
1058
|
+
for (const issue of issues) console.log(`[${issue.severity}] ${issue.path}: ${issue.message}`)
|
|
1059
|
+
|
|
1060
|
+
const errorCount = issues.filter((issue) => issue.severity === 'error').length
|
|
1061
|
+
const warningCount = issues.filter((issue) => issue.severity === 'warning').length
|
|
1062
|
+
|
|
1063
|
+
if (errorCount > 0) {
|
|
1064
|
+
return c.error({
|
|
1065
|
+
code: 'DISCOVERY_INVALID',
|
|
1066
|
+
message: `Discovery document has ${errorCount} error(s) and ${warningCount} warning(s).`,
|
|
1067
|
+
exitCode: 1,
|
|
1068
|
+
})
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
console.log(
|
|
1072
|
+
warningCount > 0
|
|
1073
|
+
? `Discovery document is valid with ${warningCount} warning(s).`
|
|
1074
|
+
: 'Discovery document is valid.',
|
|
1075
|
+
)
|
|
1076
|
+
},
|
|
1077
|
+
})
|
|
1078
|
+
|
|
918
1079
|
cli.command(account)
|
|
1080
|
+
cli.command(discover)
|
|
919
1081
|
cli.command(init)
|
|
920
1082
|
cli.command(sign)
|
|
921
1083
|
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Challenge, Credential, Mcp, Method, Receipt } from 'mppx'
|
|
|
2
2
|
import { Mppx, Transport, tempo } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
4
|
import { Methods } from 'mppx/tempo'
|
|
5
|
-
import { afterEach, describe, expect, test } from '
|
|
5
|
+
import { afterEach, describe, expect, test } from 'vp/test'
|
|
6
6
|
import * as Http from '~test/Http.js'
|
|
7
7
|
import { accounts, asset, client } from '~test/tempo/viem.js'
|
|
8
8
|
|