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,72 @@
1
+ import * as z from '../zod.js'
2
+
3
+ const uriOrPathPattern = /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/\S+|\/\S*)$/
4
+
5
+ function uriOrPath() {
6
+ return z.string().check(z.regex(uriOrPathPattern, 'Invalid URI or path'))
7
+ }
8
+
9
+ /**
10
+ * Schema for the `x-payment-info` OpenAPI extension on an operation.
11
+ *
12
+ * Only validates spec-defined fields when present; unknown fields are ignored.
13
+ * Discovery is advisory only. Runtime 402 challenges remain authoritative.
14
+ */
15
+ export const PaymentInfo = z.looseObject({
16
+ amount: z.optional(
17
+ z.union([z.null(), z.string().check(z.regex(/^(0|[1-9][0-9]*)$/, 'Invalid amount'))]),
18
+ ),
19
+ currency: z.optional(z.string()),
20
+ description: z.optional(z.string()),
21
+ intent: z.optional(z.string()),
22
+ method: z.optional(z.string()),
23
+ })
24
+ export type PaymentInfo = z.infer<typeof PaymentInfo>
25
+
26
+ const ServiceDocs = z.looseObject({
27
+ apiReference: z.optional(uriOrPath()),
28
+ homepage: z.optional(uriOrPath()),
29
+ llms: z.optional(uriOrPath()),
30
+ })
31
+
32
+ /**
33
+ * Schema for the `x-service-info` OpenAPI extension at the document root.
34
+ */
35
+ export const ServiceInfo = z.looseObject({
36
+ categories: z.optional(z.array(z.string())),
37
+ docs: z.optional(ServiceDocs),
38
+ })
39
+ export type ServiceInfo = z.infer<typeof ServiceInfo>
40
+
41
+ const OperationObject = z.looseObject({
42
+ 'x-payment-info': z.optional(PaymentInfo),
43
+ requestBody: z.optional(z.unknown()),
44
+ responses: z.optional(z.record(z.string(), z.unknown())),
45
+ summary: z.optional(z.string()),
46
+ })
47
+
48
+ const PathItem = z.looseObject({
49
+ delete: z.optional(OperationObject),
50
+ get: z.optional(OperationObject),
51
+ head: z.optional(OperationObject),
52
+ options: z.optional(OperationObject),
53
+ patch: z.optional(OperationObject),
54
+ post: z.optional(OperationObject),
55
+ put: z.optional(OperationObject),
56
+ trace: z.optional(OperationObject),
57
+ })
58
+
59
+ /**
60
+ * Minimal schema for an OpenAPI discovery document annotated with
61
+ * `x-service-info` and per-operation `x-payment-info`.
62
+ */
63
+ export const DiscoveryDocument = z.looseObject({
64
+ openapi: z.string(),
65
+ info: z.looseObject({
66
+ title: z.string(),
67
+ version: z.string(),
68
+ }),
69
+ 'x-service-info': z.optional(ServiceInfo),
70
+ paths: z.optional(z.record(z.string(), PathItem)),
71
+ })
72
+ export type DiscoveryDocument = z.infer<typeof DiscoveryDocument>
@@ -0,0 +1,425 @@
1
+ import * as Method from '../Method.js'
2
+ import * as Mppx from '../server/Mppx.js'
3
+ import * as z from '../zod.js'
4
+ import { generate } from './OpenApi.js'
5
+
6
+ const charge = Method.toServer(
7
+ Method.from({
8
+ intent: 'charge',
9
+ name: 'tempo',
10
+ schema: {
11
+ credential: { payload: z.object({ signature: z.string() }) },
12
+ request: z.object({
13
+ amount: z.string(),
14
+ currency: z.string(),
15
+ recipient: z.string(),
16
+ }),
17
+ },
18
+ }),
19
+ {
20
+ verify: async () => ({
21
+ method: 'tempo',
22
+ reference: '',
23
+ status: 'success' as const,
24
+ timestamp: '',
25
+ }),
26
+ },
27
+ )
28
+
29
+ const session = Method.toServer(
30
+ Method.from({
31
+ intent: 'session',
32
+ name: 'tempo',
33
+ schema: {
34
+ credential: { payload: z.object({ signature: z.string() }) },
35
+ request: z.object({
36
+ amount: z.union([z.null(), z.string()]),
37
+ recipient: z.string(),
38
+ }),
39
+ },
40
+ }),
41
+ {
42
+ verify: async () => ({
43
+ method: 'tempo',
44
+ reference: '',
45
+ status: 'success' as const,
46
+ timestamp: '',
47
+ }),
48
+ },
49
+ )
50
+
51
+ const subscribe = Method.toServer(
52
+ Method.from({
53
+ intent: 'subscribe',
54
+ name: 'tempo',
55
+ schema: {
56
+ credential: { payload: z.object({ signature: z.string() }) },
57
+ request: z.object({
58
+ amount: z.string(),
59
+ }),
60
+ },
61
+ }),
62
+ {
63
+ verify: async () => ({
64
+ method: 'tempo',
65
+ reference: '',
66
+ status: 'success' as const,
67
+ timestamp: '',
68
+ }),
69
+ },
70
+ )
71
+
72
+ function createMppx<const methods extends Mppx.Methods>(methods: methods) {
73
+ return Mppx.create({
74
+ methods,
75
+ realm: 'test-realm',
76
+ secretKey: 'test-secret',
77
+ })
78
+ }
79
+
80
+ describe('generate', () => {
81
+ test('generates a valid OpenAPI 3.1.0 document for legacy route config', () => {
82
+ const mppx = createMppx([charge])
83
+ const doc = generate(mppx, {
84
+ routes: [
85
+ {
86
+ intent: 'charge',
87
+ method: 'get',
88
+ options: { amount: '100', currency: '0xUSDC', recipient: '0x123' },
89
+ path: '/api/resource',
90
+ },
91
+ ],
92
+ })
93
+
94
+ expect(doc).toMatchInlineSnapshot(`
95
+ {
96
+ "info": {
97
+ "title": "test-realm",
98
+ "version": "1.0.0",
99
+ },
100
+ "openapi": "3.1.0",
101
+ "paths": {
102
+ "/api/resource": {
103
+ "get": {
104
+ "responses": {
105
+ "200": {
106
+ "description": "Successful response",
107
+ },
108
+ "402": {
109
+ "description": "Payment Required",
110
+ },
111
+ },
112
+ "x-payment-info": {
113
+ "amount": "100",
114
+ "currency": "0xUSDC",
115
+ "intent": "charge",
116
+ "method": "tempo",
117
+ "recipient": "0x123",
118
+ },
119
+ },
120
+ },
121
+ },
122
+ }
123
+ `)
124
+ })
125
+
126
+ test('supports handler-derived route config', () => {
127
+ const mppx = createMppx([charge])
128
+ const handler = mppx.charge({
129
+ amount: '50',
130
+ currency: 'usd',
131
+ description: 'Search credits',
132
+ recipient: '0x1',
133
+ })
134
+
135
+ const doc = generate(mppx, {
136
+ routes: [
137
+ {
138
+ handler,
139
+ method: 'post',
140
+ path: '/api/search',
141
+ },
142
+ ],
143
+ })
144
+
145
+ expect(doc).toMatchInlineSnapshot(`
146
+ {
147
+ "info": {
148
+ "title": "test-realm",
149
+ "version": "1.0.0",
150
+ },
151
+ "openapi": "3.1.0",
152
+ "paths": {
153
+ "/api/search": {
154
+ "post": {
155
+ "responses": {
156
+ "200": {
157
+ "description": "Successful response",
158
+ },
159
+ "402": {
160
+ "description": "Payment Required",
161
+ },
162
+ },
163
+ "x-payment-info": {
164
+ "amount": "50",
165
+ "currency": "usd",
166
+ "intent": "charge",
167
+ "method": "tempo",
168
+ "recipient": "0x1",
169
+ },
170
+ },
171
+ },
172
+ },
173
+ }
174
+ `)
175
+ })
176
+
177
+ test('handles null amount for session intent', () => {
178
+ const mppx = createMppx([session])
179
+ const doc = generate(mppx, {
180
+ routes: [
181
+ {
182
+ intent: 'session',
183
+ method: 'post',
184
+ options: { amount: null, recipient: '0x123' },
185
+ path: '/api/stream',
186
+ },
187
+ ],
188
+ })
189
+
190
+ expect(doc).toMatchInlineSnapshot(`
191
+ {
192
+ "info": {
193
+ "title": "test-realm",
194
+ "version": "1.0.0",
195
+ },
196
+ "openapi": "3.1.0",
197
+ "paths": {
198
+ "/api/stream": {
199
+ "post": {
200
+ "responses": {
201
+ "200": {
202
+ "description": "Successful response",
203
+ },
204
+ "402": {
205
+ "description": "Payment Required",
206
+ },
207
+ },
208
+ "x-payment-info": {
209
+ "amount": null,
210
+ "intent": "session",
211
+ "method": "tempo",
212
+ "recipient": "0x123",
213
+ },
214
+ },
215
+ },
216
+ },
217
+ }
218
+ `)
219
+ })
220
+
221
+ test('includes x-service-info when provided', () => {
222
+ const mppx = createMppx([charge])
223
+ const doc = generate(mppx, {
224
+ routes: [],
225
+ serviceInfo: {
226
+ categories: ['ai'],
227
+ docs: { homepage: 'https://example.com' },
228
+ },
229
+ })
230
+
231
+ expect(doc).toMatchInlineSnapshot(`
232
+ {
233
+ "info": {
234
+ "title": "test-realm",
235
+ "version": "1.0.0",
236
+ },
237
+ "openapi": "3.1.0",
238
+ "paths": {},
239
+ "x-service-info": {
240
+ "categories": [
241
+ "ai",
242
+ ],
243
+ "docs": {
244
+ "homepage": "https://example.com",
245
+ },
246
+ },
247
+ }
248
+ `)
249
+ })
250
+
251
+ test('multi-route document with mixed intents', () => {
252
+ const mppx = createMppx([charge, session])
253
+ const doc = generate(mppx, {
254
+ info: { title: 'Multi-Route API', version: '2.0.0' },
255
+ routes: [
256
+ {
257
+ intent: 'charge',
258
+ method: 'post',
259
+ options: { amount: '500', currency: '0xUSDC', recipient: '0xABC' },
260
+ path: '/api/search',
261
+ summary: 'Search the index',
262
+ requestBody: {
263
+ content: { 'application/json': { schema: { type: 'object' } } },
264
+ },
265
+ },
266
+ {
267
+ intent: 'session',
268
+ method: 'post',
269
+ options: { amount: null, recipient: '0xABC' },
270
+ path: '/api/stream',
271
+ },
272
+ {
273
+ intent: 'charge',
274
+ method: 'get',
275
+ options: { amount: '100', currency: '0xUSDC', recipient: '0xABC' },
276
+ path: '/api/models',
277
+ },
278
+ ],
279
+ serviceInfo: {
280
+ categories: ['ai', 'search'],
281
+ docs: {
282
+ apiReference: 'https://example.com/api',
283
+ homepage: 'https://example.com',
284
+ llms: 'https://example.com/llms.txt',
285
+ },
286
+ },
287
+ })
288
+
289
+ expect(doc).toMatchInlineSnapshot(`
290
+ {
291
+ "info": {
292
+ "title": "Multi-Route API",
293
+ "version": "2.0.0",
294
+ },
295
+ "openapi": "3.1.0",
296
+ "paths": {
297
+ "/api/models": {
298
+ "get": {
299
+ "responses": {
300
+ "200": {
301
+ "description": "Successful response",
302
+ },
303
+ "402": {
304
+ "description": "Payment Required",
305
+ },
306
+ },
307
+ "x-payment-info": {
308
+ "amount": "100",
309
+ "currency": "0xUSDC",
310
+ "intent": "charge",
311
+ "method": "tempo",
312
+ "recipient": "0xABC",
313
+ },
314
+ },
315
+ },
316
+ "/api/search": {
317
+ "post": {
318
+ "requestBody": {
319
+ "content": {
320
+ "application/json": {
321
+ "schema": {
322
+ "type": "object",
323
+ },
324
+ },
325
+ },
326
+ },
327
+ "responses": {
328
+ "200": {
329
+ "description": "Successful response",
330
+ },
331
+ "402": {
332
+ "description": "Payment Required",
333
+ },
334
+ },
335
+ "summary": "Search the index",
336
+ "x-payment-info": {
337
+ "amount": "500",
338
+ "currency": "0xUSDC",
339
+ "intent": "charge",
340
+ "method": "tempo",
341
+ "recipient": "0xABC",
342
+ },
343
+ },
344
+ },
345
+ "/api/stream": {
346
+ "post": {
347
+ "responses": {
348
+ "200": {
349
+ "description": "Successful response",
350
+ },
351
+ "402": {
352
+ "description": "Payment Required",
353
+ },
354
+ },
355
+ "x-payment-info": {
356
+ "amount": null,
357
+ "intent": "session",
358
+ "method": "tempo",
359
+ "recipient": "0xABC",
360
+ },
361
+ },
362
+ },
363
+ },
364
+ "x-service-info": {
365
+ "categories": [
366
+ "ai",
367
+ "search",
368
+ ],
369
+ "docs": {
370
+ "apiReference": "https://example.com/api",
371
+ "homepage": "https://example.com",
372
+ "llms": "https://example.com/llms.txt",
373
+ },
374
+ },
375
+ }
376
+ `)
377
+ })
378
+
379
+ test('passes through custom intents and extra params', () => {
380
+ const mppx = createMppx([subscribe])
381
+ const doc = generate(mppx, {
382
+ routes: [
383
+ {
384
+ intent: 'subscribe',
385
+ method: 'post',
386
+ options: { amount: '100', interval: 'monthly', recipient: '0xABC' },
387
+ path: '/api/subscribe',
388
+ summary: 'Monthly subscription',
389
+ },
390
+ ],
391
+ })
392
+
393
+ expect(doc).toMatchInlineSnapshot(`
394
+ {
395
+ "info": {
396
+ "title": "test-realm",
397
+ "version": "1.0.0",
398
+ },
399
+ "openapi": "3.1.0",
400
+ "paths": {
401
+ "/api/subscribe": {
402
+ "post": {
403
+ "responses": {
404
+ "200": {
405
+ "description": "Successful response",
406
+ },
407
+ "402": {
408
+ "description": "Payment Required",
409
+ },
410
+ },
411
+ "summary": "Monthly subscription",
412
+ "x-payment-info": {
413
+ "amount": "100",
414
+ "intent": "subscribe",
415
+ "interval": "monthly",
416
+ "method": "tempo",
417
+ "recipient": "0xABC",
418
+ },
419
+ },
420
+ },
421
+ },
422
+ }
423
+ `)
424
+ })
425
+ })
@@ -0,0 +1,224 @@
1
+ import type * as Method from '../Method.js'
2
+ import type { ServiceInfo } from './Discovery.js'
3
+
4
+ export type DiscoveryHandler = ((...args: any[]) => unknown) & {
5
+ _internal?: {
6
+ _canonicalRequest: Record<string, unknown>
7
+ intent: string
8
+ name: string
9
+ }
10
+ }
11
+
12
+ export type LegacyRouteConfig = {
13
+ intent: string
14
+ method: string
15
+ options: Record<string, unknown>
16
+ path: string
17
+ requestBody?: Record<string, unknown>
18
+ summary?: string
19
+ }
20
+
21
+ export type HandlerRouteConfig = {
22
+ handler: DiscoveryHandler
23
+ method: string
24
+ path: string
25
+ requestBody?: Record<string, unknown>
26
+ summary?: string
27
+ }
28
+
29
+ export type RouteConfig = HandlerRouteConfig | LegacyRouteConfig
30
+
31
+ export type GenerateConfig = {
32
+ info?: { title?: string; version?: string } | undefined
33
+ routes: RouteConfig[]
34
+ serviceInfo?: ServiceInfo | undefined
35
+ }
36
+
37
+ export type GenerateProxyConfig = {
38
+ basePath?: string | undefined
39
+ info?: { title?: string; version?: string } | undefined
40
+ routes: Array<{
41
+ method: string
42
+ path: string
43
+ payment: Record<string, unknown> | null
44
+ requestBody?: Record<string, unknown>
45
+ summary?: string
46
+ }>
47
+ serviceInfo?: ServiceInfo | undefined
48
+ }
49
+
50
+ type ResolvedRoute = {
51
+ method: string
52
+ path: string
53
+ payment: Record<string, unknown> | null
54
+ requestBody?: Record<string, unknown>
55
+ summary?: string
56
+ }
57
+
58
+ /**
59
+ * Generates an OpenAPI 3.1.0 discovery document from an mppx instance
60
+ * and route configuration.
61
+ */
62
+ export function generate(
63
+ mppx: { methods: readonly Method.AnyServer[]; realm: string },
64
+ config: GenerateConfig,
65
+ ): Record<string, unknown> {
66
+ const methods = mppx.methods
67
+ const methodsByKey = new Map<string, Method.AnyServer>()
68
+ const intentCount: Record<string, number> = {}
69
+
70
+ for (const mi of methods) {
71
+ methodsByKey.set(`${mi.name}/${mi.intent}`, mi)
72
+ intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
73
+ }
74
+ for (const mi of methods) {
75
+ if (intentCount[mi.intent] === 1) methodsByKey.set(mi.intent, mi)
76
+ }
77
+
78
+ const routes = config.routes.map((route) => resolveRoute(route, methodsByKey))
79
+ return createDocument({
80
+ info: {
81
+ title: config.info?.title ?? mppx.realm,
82
+ version: config.info?.version ?? '1.0.0',
83
+ },
84
+ routes,
85
+ serviceInfo: config.serviceInfo,
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Generates an OpenAPI 3.1.0 discovery document for a proxy surface.
91
+ */
92
+ export function generateProxy(config: GenerateProxyConfig): Record<string, unknown> {
93
+ const routes = config.routes.map((route) => ({
94
+ ...route,
95
+ path: withBasePath(config.basePath, route.path),
96
+ }))
97
+
98
+ return createDocument({
99
+ info: {
100
+ title: config.info?.title ?? 'API Proxy',
101
+ version: config.info?.version ?? '1.0.0',
102
+ },
103
+ routes,
104
+ serviceInfo: config.serviceInfo,
105
+ })
106
+ }
107
+
108
+ function createDocument(config: {
109
+ info: { title: string; version: string }
110
+ routes: ResolvedRoute[]
111
+ serviceInfo?: ServiceInfo | undefined
112
+ }) {
113
+ const paths: Record<string, Record<string, unknown>> = {}
114
+
115
+ for (const route of config.routes) {
116
+ const method = route.method.toLowerCase()
117
+ const operation: Record<string, unknown> = {
118
+ responses: {
119
+ ...(route.payment ? { '402': { description: 'Payment Required' } } : {}),
120
+ '200': { description: 'Successful response' },
121
+ },
122
+ }
123
+
124
+ if (route.payment) operation['x-payment-info'] = route.payment
125
+ if (route.summary) operation.summary = route.summary
126
+ if (route.requestBody) operation.requestBody = route.requestBody
127
+
128
+ if (!paths[route.path]) paths[route.path] = {}
129
+ paths[route.path]![method] = operation
130
+ }
131
+
132
+ const doc: Record<string, unknown> = {
133
+ info: config.info,
134
+ openapi: '3.1.0',
135
+ paths,
136
+ }
137
+ if (config.serviceInfo) doc['x-service-info'] = config.serviceInfo
138
+ return doc
139
+ }
140
+
141
+ function resolveRoute(
142
+ route: RouteConfig,
143
+ methodsByKey: Map<string, Method.AnyServer>,
144
+ ): ResolvedRoute {
145
+ if ('handler' in route) {
146
+ const internal = route.handler._internal
147
+ if (!internal)
148
+ throw new Error(
149
+ `Route ${route.method.toUpperCase()} ${route.path} is missing discovery metadata`,
150
+ )
151
+ return {
152
+ method: route.method,
153
+ path: route.path,
154
+ payment: paymentInfoFromCanonical({
155
+ canonicalRequest: internal._canonicalRequest,
156
+ intent: internal.intent,
157
+ method: internal.name,
158
+ }),
159
+ ...(route.requestBody ? { requestBody: route.requestBody } : {}),
160
+ ...(route.summary ? { summary: route.summary } : {}),
161
+ }
162
+ }
163
+
164
+ const mi = methodsByKey.get(route.intent)
165
+ if (!mi) {
166
+ throw new Error(
167
+ `Unknown intent "${route.intent}" for route ${route.method.toUpperCase()} ${route.path}. Available: ${[...methodsByKey.keys()].join(', ')}`,
168
+ )
169
+ }
170
+
171
+ return {
172
+ method: route.method,
173
+ path: route.path,
174
+ payment: paymentInfoFromCanonical({
175
+ canonicalRequest: route.options,
176
+ intent: mi.intent,
177
+ method: mi.name,
178
+ }),
179
+ ...(route.requestBody ? { requestBody: route.requestBody } : {}),
180
+ ...(route.summary ? { summary: route.summary } : {}),
181
+ }
182
+ }
183
+
184
+ function paymentInfoFromCanonical(route: {
185
+ canonicalRequest: Record<string, unknown>
186
+ intent: string
187
+ method: string
188
+ }) {
189
+ const { canonicalRequest, intent, method } = route
190
+ const methodDetails = (canonicalRequest.methodDetails ?? {}) as Record<string, unknown>
191
+
192
+ const amount = pickString(canonicalRequest.amount) ?? pickString(methodDetails.amount) ?? null
193
+ const currency = pickString(canonicalRequest.currency) ?? pickString(methodDetails.currency)
194
+ const description = pickString(canonicalRequest.description)
195
+
196
+ const base: Record<string, unknown> = {
197
+ amount,
198
+ ...(currency ? { currency } : {}),
199
+ ...(description ? { description } : {}),
200
+ intent,
201
+ method,
202
+ }
203
+
204
+ // Forward any extra canonical params that aren't already covered.
205
+ const reserved = new Set(['amount', 'currency', 'description', 'methodDetails'])
206
+ for (const [key, value] of Object.entries(canonicalRequest)) {
207
+ if (!reserved.has(key) && value !== undefined) base[key] = value
208
+ }
209
+
210
+ return base
211
+ }
212
+
213
+ function pickString(value: unknown) {
214
+ return typeof value === 'string' ? value : undefined
215
+ }
216
+
217
+ function withBasePath(basePath: string | undefined, path: string) {
218
+ if (!basePath) return path
219
+ const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`
220
+ const trimmedBasePath = normalizedBasePath.endsWith('/')
221
+ ? normalizedBasePath.slice(0, -1)
222
+ : normalizedBasePath
223
+ return `${trimmedBasePath}${path}`
224
+ }