feat: service discovery tooling (OpenAPI + x-payment-info + x-service-info)#178
feat: service discovery tooling (OpenAPI + x-payment-info + x-service-info)#178brendanjryan wants to merge 7 commits intomainfrom
Conversation
Allow compose() to accept [mppx.tempo.charge, { amount: '1' }] tuples
in addition to Method.AnyServer objects and string keys.
Each MethodFn is tagged with a _method property pointing back to its
source Method.AnyServer during create(). compose() checks for _method
to resolve the handler lookup key.
- Tag MethodFn with _method metadata in nested handler setup
- Widen ComposeEntry type with third union branch for MethodFn refs
- Add runtime branch in composeFn for _method resolution
- Add tests for handler ref syntax and mixed usage
…-info) Implements mppx discovery per draft-payment-discovery-00 (mpp-specs #166). Phase 1 — Schema + Validator + CLI: - Zod schemas for PaymentInfo, ServiceInfo, DiscoveryDocument - validate() with structural parsing + semantic checks - 'discover validate <url-or-file>' CLI subcommand - 26 tests (Discovery + Validate) Phase 2 — OpenAPI Generator: - generate(mppx, config) builds OpenAPI 3.1.0 doc from mppx.methods - Annotates routes with x-service-info and x-payment-info extensions - 6 tests Phase 3 — Framework Plugins: - Hono: discovery(app, mppx, config) mounts GET /openapi.json - Express: discovery(app, mppx, config) mounts GET /openapi.json - Next.js: discovery(mppx, config) returns a route handler Phase 4 — Auto-introspection (Hono): - discovery(app, mppx, { auto: true }) walks app.routes and matches handlers with _internal metadata to build RouteConfig[] automatically Infrastructure: - ./discovery export in package.json - mppx/discovery alias in vitest.config.ts - src/discovery/index.ts re-exports
commit: |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Auto-introspection never finds
_internalon middleware handlers- Eagerly call intent(options) at registration time and forward _internal metadata to the MiddlewareHandler via Object.assign, so introspectRoutes() can find it on route.handler.
- ✅ Fixed: Path-item schema rejects valid OpenAPI non-operation keys
- Wrapped the operation object schema in z.union with z.unknown() fallback so non-operation path item values (like parameters arrays) pass validation, and added a type guard in Validate.ts to skip non-object entries.
Or push these changes by commenting:
@cursor push 77278ca20a
Preview (77278ca20a)
diff --git a/src/discovery/Discovery.ts b/src/discovery/Discovery.ts
--- a/src/discovery/Discovery.ts
+++ b/src/discovery/Discovery.ts
@@ -51,12 +51,15 @@
z.string(),
z.record(
z.string(),
- z.object({
- 'x-payment-info': z.optional(PaymentInfo),
- responses: z.optional(z.record(z.string(), z.unknown())),
- requestBody: z.optional(z.unknown()),
- summary: z.optional(z.string()),
- }),
+ z.union([
+ z.object({
+ 'x-payment-info': z.optional(PaymentInfo),
+ responses: z.optional(z.record(z.string(), z.unknown())),
+ requestBody: z.optional(z.unknown()),
+ summary: z.optional(z.string()),
+ }),
+ z.unknown(),
+ ]),
),
),
),
diff --git a/src/discovery/OpenApi.test.ts b/src/discovery/OpenApi.test.ts
--- a/src/discovery/OpenApi.test.ts
+++ b/src/discovery/OpenApi.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest'
-import * as z from '../zod.js'
import * as Method from '../Method.js'
import * as Mppx from '../server/Mppx.js'
+import * as z from '../zod.js'
import { generate } from './OpenApi.js'
const charge = Method.toServer(
@@ -17,7 +17,14 @@
}),
},
}),
- { verify: async () => ({ status: 'success' as const, method: 'tempo', timestamp: '', reference: '' }) },
+ {
+ verify: async () => ({
+ status: 'success' as const,
+ method: 'tempo',
+ timestamp: '',
+ reference: '',
+ }),
+ },
)
const session = Method.toServer(
@@ -32,7 +39,14 @@
}),
},
}),
- { verify: async () => ({ status: 'success' as const, method: 'tempo', timestamp: '', reference: '' }) },
+ {
+ verify: async () => ({
+ status: 'success' as const,
+ method: 'tempo',
+ timestamp: '',
+ reference: '',
+ }),
+ },
)
function createMppx(methods: Mppx.Methods) {
diff --git a/src/discovery/Validate.ts b/src/discovery/Validate.ts
--- a/src/discovery/Validate.ts
+++ b/src/discovery/Validate.ts
@@ -36,9 +36,12 @@
for (const [pathKey, methods] of Object.entries(paths)) {
for (const [method, operation] of Object.entries(methods)) {
+ if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) continue
+
const opPath = `paths.${pathKey}.${method}`
+ const op = operation as Record<string, unknown>
- const rawPaymentInfo = (operation as Record<string, unknown>)['x-payment-info']
+ const rawPaymentInfo = op['x-payment-info']
if (!rawPaymentInfo) continue
const paymentResult = PaymentInfo.safeParse(rawPaymentInfo)
@@ -53,7 +56,7 @@
continue
}
- const responses = operation.responses as Record<string, unknown> | undefined
+ const responses = op.responses as Record<string, unknown> | undefined
if (!responses || !('402' in responses)) {
errors.push({
path: `${opPath}.responses`,
@@ -62,7 +65,7 @@
})
}
- if (!operation.requestBody) {
+ if (!op.requestBody) {
errors.push({
path: opPath,
message: 'Operation with x-payment-info has no requestBody',
diff --git a/src/discovery/index.ts b/src/discovery/index.ts
--- a/src/discovery/index.ts
+++ b/src/discovery/index.ts
@@ -1,10 +1,10 @@
export {
DiscoveryDocument,
+ type DiscoveryDocument as DiscoveryDocumentType,
PaymentInfo,
+ type PaymentInfo as PaymentInfoType,
ServiceInfo,
- type DiscoveryDocument as DiscoveryDocumentType,
- type PaymentInfo as PaymentInfoType,
type ServiceInfo as ServiceInfoType,
} from './Discovery.js'
+export { type GenerateConfig, generate, type RouteConfig } from './OpenApi.js'
export { type ValidationError, validate } from './Validate.js'
-export { type GenerateConfig, type RouteConfig, generate } from './OpenApi.js'
diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts
--- a/src/middlewares/hono.ts
+++ b/src/middlewares/hono.ts
@@ -54,12 +54,14 @@
intent: intent,
options: intent extends (options: infer options) => any ? options : never,
): MiddlewareHandler {
- return async (c, next) => {
- const result = await intent(options)(c.req.raw)
+ const configured = intent(options)
+ const handler: MiddlewareHandler = async (c, next) => {
+ const result = await configured(c.req.raw)
if (result.status === 402) return result.challenge
await next()
c.res = result.withReceipt(c.res)
}
+ return Object.assign(handler, { _internal: (configured as any)._internal })
}
export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
@@ -117,7 +119,10 @@
* Walks Hono's `app.routes` and matches them to mppx handlers,
* building `RouteConfig[]` automatically.
*/
-function introspectRoutes(app: Hono<any>, _mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string }): RouteConfig[] {
+function introspectRoutes(
+ app: Hono<any>,
+ _mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
+): RouteConfig[] {
const routes: RouteConfig[] = []
const appRoutes = (app as any).routes as
| { method: string; path: string; handler: any }[]This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
| } | ||
|
|
||
| return routes | ||
| } |
There was a problem hiding this comment.
Auto-introspection never finds _internal on middleware handlers
High Severity
introspectRoutes() looks for route.handler._internal on Hono route handlers, but the payment() wrapper returns a plain MiddlewareHandler (async (c, next) => { ... }) that never carries _internal. The _internal metadata is only attached to the configured handler created inside createMethodFn (via Object.assign), which is called lazily at request time inside the middleware — not at route registration time. This means auto: true discovery will always produce an empty routes array and an empty OpenAPI document.
Additional Locations (1)
| }), | ||
| ), | ||
| ), | ||
| ), |
There was a problem hiding this comment.
Path-item schema rejects valid OpenAPI non-operation keys
Medium Severity
The DiscoveryDocument schema models each path item as z.record(z.string(), z.object({...})), requiring every value to match the operation object shape. Real OpenAPI path items can contain non-operation keys like parameters (an array), summary, description, or servers. These values would fail the operation object schema, causing validate() to reject valid OpenAPI documents — particularly problematic for the discover validate CLI command that targets external documents.
| const result = DiscoveryDocument.safeParse(doc) | ||
| if (!result.success) { | ||
| for (const issue of result.error.issues) { | ||
| errors.push({ |
There was a problem hiding this comment.
can this check the validity of the openapi spec in general too?
- PaymentInfo.intent now z.string() to match core Method.intent - generate() throws on unknown intent instead of silent skip - Validate requestBody warning only for POST/PUT/PATCH (not GET) - Hono auto-introspection: propagate _internal metadata through wrap() - RouteConfig.method widened to string - GenerateConfig.info allows overriding title/version - Updated tests for new behavior
| type ServiceInfo as ServiceInfoType, | ||
| } from './Discovery.js' | ||
| export { type GenerateConfig, generate, type RouteConfig } from './OpenApi.js' | ||
| export { type ValidationError, validate } from './Validate.js' |
There was a problem hiding this comment.
might be good to follow module-first convention to keep consistent with rest of library:
export * as Discovery from './Discovery.js'
export * as OpenApi from './OpenApi.js'and consume as:
import { OpenApi } from 'mppx/discovery'
const doc = OpenApi.generate(...)
const valid = OpenApi.validate(doc)


Summary
Implements mppx discovery per
draft-payment-discovery-00(mpp-specs #166).Services publish an OpenAPI 3.x doc at
/openapi.jsonannotated with two extensions:x-service-info(top-level): service categories and documentation linksx-payment-info(per-operation): payment intent, method, amount, and currencyExample interfaces
Hono (auto-introspection):
Express:
Next.js:
CLI:
Programmatic: