From d547073485282b837aa63912fdb606b346d5c551 Mon Sep 17 00:00:00 2001 From: Monserrat Mendoza <210256392+M0nsxx@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:20:46 -0600 Subject: [PATCH] feat(sdk): x402Serve() routes can declare the Bazaar discovery extension Routes in X402ServeConfig.routes now accept optional input/inputSchema/ output. When any is present, x402Serve() loads @x402/extensions/bazaar (dynamic import, same pattern as the other @x402/* packages) and sets declareDiscoveryExtension({ input, inputSchema, output }) as that route's extensions - same shape PREMIUM_ROUTES in packages/agent/src/middleware/x402.ts already uses directly. Without those fields, a route is unchanged: no extensions key, no new required dependency. Verified against Rail402's hosted testnet facilitator with a real x402Serve()-protected route on a public host: after a settled payment (tx 7fb9bca13c9451fdfa11b8e909a4b6c3d49fe6411b7865f4a9bf11da8a465139, Stellar testnet, ledger 5608942), the resource was cataloged at GET https://testnet.rail402.dev/discovery/resources, trust origin_verified. Closes #217 Co-Authored-By: Claude Sonnet 5 --- packages/sdk/CHANGELOG.md | 4 + packages/sdk/README.md | 22 ++ packages/sdk/package-lock.json | 236 +++++++++++++++---- packages/sdk/package.json | 2 + packages/sdk/src/index.ts | 51 +++- packages/sdk/src/x402serve-discovery.test.ts | 182 ++++++++++++++ 6 files changed, 445 insertions(+), 52 deletions(-) create mode 100644 packages/sdk/src/x402serve-discovery.test.ts diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 73d2406..9e1efdb 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the `nirium` package are documented here. ## Unreleased +### Added + +- `x402Serve()` routes can declare `input`, `inputSchema`, and/or `output` ([#217](https://github.com/nirium-protocol/nirium/issues/217)): when any is present, the route is announced via `@x402/extensions/bazaar`'s `declareDiscoveryExtension()` as that route's `extensions`, letting a Bazaar (Rail402 on Stellar testnet, Coinbase's CDP Bazaar where applicable) catalog it once a payment for it settles. Without those fields, a route behaves exactly as before - no `extensions` key, no new required dependency (`@x402/extensions` is dynamically loaded only when at least one route opts in). Verified end to end against Rail402's hosted testnet facilitator: a settled payment against a public `x402Serve()`-protected route got the resource listed at `GET https://testnet.rail402.dev/discovery/resources`, trust `origin_verified`. + ## 0.17.0 - 2026-11-05 ### Added diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 73ae2fb..4a7dd71 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -218,6 +218,28 @@ Runs on **your own server**, not Nirium's - `x402Serve()` is a client-side funct **Known limitation:** by default `x402Serve()` does not protect against replay and does not rate-limit callers. It is turned on by passing `guard` (see below). +#### Getting listed in an x402 Bazaar (optional) + +Add `input`, `inputSchema`, and/or `output` to a route to declare its discovery metadata ([`@x402/extensions/bazaar`](https://www.npmjs.com/package/@x402/extensions)'s `declareDiscoveryExtension()`). A facilitator that reads the Bazaar extension (for example [Rail402](https://docs.rail402.dev/bazaar/discovery) on Stellar testnet) catalogs the route the first time a payment for it settles, so agents can discover it by searching instead of needing the URL hardcoded: + +```typescript +app.use('/premium', x402Serve({ + payTo: 'G...', + routes: { + 'GET /signals': { + price: '$0.02', + description: 'Nirium arbitrage signals', + input: { count: 20 }, + inputSchema: { properties: { count: { type: 'integer', maximum: 100 } } }, + output: { example: { signals: [{ pair: 'XLM/USDC', confidence: 0.85 }] } }, + }, + }, + facilitatorApiKey: '...', +})); +``` + +Without `input`/`inputSchema`/`output`, a route behaves exactly as before - no `extensions` key, no new required dependency (`@x402/extensions` is only ever loaded if at least one route uses them). + #### Replay and rate-limit guard (optional) Without `guard`, `x402Serve()` verifies and settles the payment and does nothing else. It does not stop the same signed payment from being sent twice, and it does not limit how often one caller can hit a route. Pass `guard` to get both: diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 7318fc2..6b4316e 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -22,6 +22,8 @@ "@types/node": "^26.2.0", "@types/ws": "^8.18.1", "@x402/core": "^2.28.0", + "@x402/express": "^2.28.0", + "@x402/extensions": "^2.28.0", "jest": "^30.4.2", "ts-jest": "^29.4.12", "typescript": "^5.9.3" @@ -1331,9 +1333,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1355,9 +1354,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1379,9 +1375,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1403,9 +1396,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1427,9 +1417,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1451,9 +1438,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1602,6 +1586,52 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@signinwithethereum/siwe": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.1.tgz", + "integrity": "sha512-CQEQhCIgN9blil1YpaGR+wvGPdsBVK6zed5DBu03s1IdbSO3kA/n7Zp+SAkZGiHy3HY6OJm1Su0/Bd33vKK6nQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@signinwithethereum/siwe-parser": "^4.2.1" + }, + "peerDependencies": { + "ethers": "^5.7.0 || ^6.13.0", + "viem": "^2.7.0" + }, + "peerDependenciesMeta": { + "ethers": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/@signinwithethereum/siwe-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.1.tgz", + "integrity": "sha512-GAdwNiBaRpUMTiFfPrbQ3e/RBhPbEhRRN1sZmDMLgLEZuKqKwO7V0ksVzCerE9l8HFKTNw5R/q3kPDCWJRz4zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.7.0", + "apg-js": "^4.4.0" + } + }, + "node_modules/@signinwithethereum/siwe-parser/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -1938,9 +1968,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1955,9 +1982,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1972,9 +1996,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1989,9 +2010,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +2024,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2023,9 +2038,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2040,9 +2052,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2057,9 +2066,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2074,9 +2080,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2091,9 +2094,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2184,6 +2184,64 @@ "zod": "^4.6.6" } }, + "node_modules/@x402/express": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.30.0.tgz", + "integrity": "sha512-M+lTPWOPy2tqJhDDF1ipjgGWc9uTOt+oLrgyKxCLFmCuS48LbfWvNWVOYAge/kSIo9K52v9w+9h0toh3g7JXnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.30.0", + "@x402/extensions": "~2.30.0" + }, + "peerDependencies": { + "@x402/paywall": "^2.30.0", + "express": "^4.22.3 || ^5.3.0" + }, + "peerDependenciesMeta": { + "@x402/paywall": { + "optional": true + } + } + }, + "node_modules/@x402/express/node_modules/@x402/core": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.30.0.tgz", + "integrity": "sha512-gvMxCum2QlNI6jI/3NL1jdrmOgtX9sWEWxSIOmeI67/5ndOJvYOtP7JZLSO8o8af+O+A+Vhhjza1NCda0yVppg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.6.6" + } + }, + "node_modules/@x402/extensions": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.30.0.tgz", + "integrity": "sha512-Lqu8hYkFAIhzarWa8V1272tyolFAW7WjHrxQ7we5P0fEZ8oMXOq1hna2X0FXQetZoOFzBDwSWBBMsrqE8ZzvcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.0", + "@scure/base": "^1.2.6", + "@signinwithethereum/siwe": "^4.1.0", + "@x402/core": "~2.30.0", + "ajv": "^8.18.0", + "jose": "^5.9.6", + "tweetnacl": "^1.0.3", + "viem": "^2.48.11", + "zod": "^4.6.6" + } + }, + "node_modules/@x402/extensions/node_modules/@x402/core": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.30.0.tgz", + "integrity": "sha512-gvMxCum2QlNI6jI/3NL1jdrmOgtX9sWEWxSIOmeI67/5ndOJvYOtP7JZLSO8o8af+O+A+Vhhjza1NCda0yVppg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.6.6" + } + }, "node_modules/@x402/fetch": { "version": "2.29.0", "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.29.0.tgz", @@ -2239,6 +2297,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2308,6 +2383,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3081,6 +3163,13 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3088,6 +3177,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.4.tgz", @@ -4276,6 +4382,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4317,6 +4433,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4910,6 +5033,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -5346,6 +5479,13 @@ "license": "0BSD", "optional": true }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b5cc315..026962a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -62,6 +62,8 @@ "@types/node": "^26.2.0", "@types/ws": "^8.18.1", "@x402/core": "^2.28.0", + "@x402/express": "^2.28.0", + "@x402/extensions": "^2.28.0", "jest": "^30.4.2", "ts-jest": "^29.4.12", "typescript": "^5.9.3" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 012f367..0574cab 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1392,8 +1392,23 @@ const sdkVersionForTelemetry = (): string | undefined => { export interface X402ServeConfig { /** Cuenta Stellar que recibe los pagos. Sin esto no hay a quién cobrarle. */ payTo: string; - /** `'GET /signals': '$0.02'` — el método es opcional y default GET. */ - routes: Record; + /** + * `'GET /signals': '$0.02'` — el método es opcional y default GET. + * + * `input`/`inputSchema`/`output` son opcionales y activan el discovery + * de x402 Bazaar (misma forma que `PREMIUM_ROUTES` en + * packages/agent/src/middleware/x402.ts): si cualquiera está presente, + * la ruta se anuncia vía `declareDiscoveryExtension()` y aparece en + * `GET /discovery/resources` del facilitador. Sin ellos, cero cambio de + * comportamiento respecto a antes de que este campo existiera. + */ + routes: Record; network?: 'stellar:testnet' | 'stellar:pubnet'; /** Default: OpenZeppelin Channels, el facilitador canónico de Stellar. */ facilitatorUrl?: string; @@ -1453,7 +1468,7 @@ export function x402Serve(config: X402ServeConfig): any { } catch (err: any) { const missing = /cannot find (module|package)/i.test(String(err?.message)); throw new Error(missing - ? `x402Serve needs "${m}". Install it alongside the SDK: npm i @x402/express @x402/core @x402/stellar` + ? `x402Serve needs "${m}". Install it alongside the SDK: npm i @x402/express @x402/core @x402/stellar @x402/extensions` : `x402Serve could not load "${m}": ${err?.message}`); } }; @@ -1467,7 +1482,14 @@ export function x402Serve(config: X402ServeConfig): any { // 'GET /signals' y '/signals' significan lo mismo: el método se omite // porque casi todo cobro es un GET, y obligar a escribirlo es fricción. + // + // `discoverySpecs` guarda input/inputSchema/output por separado de + // `routes` porque convertirlos en `extensions.bazaar` requiere + // `declareDiscoveryExtension()` de `@x402/extensions/bazaar`, un import + // dinámico — y todo el resto de esta función carga sus dependencias + // dentro de build(), nunca aquí arriba de forma síncrona. const routes: Record = {}; + const discoverySpecs: Record = {}; for (const [key, value] of entries) { const spec = typeof value === 'string' ? { price: value } : value; const routeKey = /^[A-Z]+\s/.test(key) ? key : `GET ${key}`; @@ -1484,6 +1506,13 @@ export function x402Serve(config: X402ServeConfig): any { payTo: config.payTo, }, }; + if ('input' in spec || 'inputSchema' in spec || 'output' in spec) { + discoverySpecs[routeKey] = { + input: (spec as any).input, + inputSchema: (spec as any).inputSchema, + output: (spec as any).output, + }; + } } const facilitatorKeyHash = config.facilitatorApiKey @@ -1534,13 +1563,27 @@ export function x402Serve(config: X402ServeConfig): any { let cached: any; const build = async () => { - const [{ paymentMiddlewareFromConfig }, { HTTPFacilitatorClient }, { ExactStellarScheme: ServerScheme }] = + const hasDiscovery = Object.keys(discoverySpecs).length > 0; + const [{ paymentMiddlewareFromConfig }, { HTTPFacilitatorClient }, { ExactStellarScheme: ServerScheme }, bazaar] = await Promise.all([ load('@x402/express'), load('@x402/core/server'), load('@x402/stellar/exact/server'), + hasDiscovery ? load('@x402/extensions/bazaar') : Promise.resolve(undefined), ]); + if (bazaar) { + const { declareDiscoveryExtension } = bazaar; + for (const [routeKey, discovery] of Object.entries(discoverySpecs)) { + // Solo los campos que de verdad se pasaron — `output: undefined` + // no debería terminar escrito literal en el discovery. + const fields = Object.fromEntries( + Object.entries(discovery).filter(([, v]) => v !== undefined), + ); + routes[routeKey].extensions = declareDiscoveryExtension(fields); + } + } + const facilitator = new HTTPFacilitatorClient({ url: facilitatorUrl, ...(config.facilitatorApiKey ? { diff --git a/packages/sdk/src/x402serve-discovery.test.ts b/packages/sdk/src/x402serve-discovery.test.ts new file mode 100644 index 0000000..7421f07 --- /dev/null +++ b/packages/sdk/src/x402serve-discovery.test.ts @@ -0,0 +1,182 @@ +/** + * x402Serve() — Bazaar discovery extension. + * + * `routes` in X402ServeConfig can carry optional `input`/`inputSchema`/ + * `output` fields. When present, x402Serve() must declare them via + * `declareDiscoveryExtension()` (same pattern already used in + * packages/agent/src/middleware/x402.ts's PREMIUM_ROUTES) and attach the + * result as `extensions` on that route. Routes without any of those three + * fields must get no `extensions` key at all — zero behavior change for + * existing callers. + * + * These tests intercept what gets passed into `paymentMiddlewareFromConfig` + * (the real call x402Serve() makes once it builds, on the first request) + * rather than re-implementing the facilitator — @x402/express, + * @x402/core/server, @x402/stellar/exact/server and @x402/extensions/bazaar + * are all mocked, same as @x402/fetch/@x402/stellar are mocked in + * x402serve-smoke.test.ts for the same reason (they're ESM-only or require + * a live facilitator). + */ + +jest.mock('ws', () => ({ default: class WS {}, WebSocket: class WS {} })); +jest.mock('@x402/fetch', () => ({ + x402Client: class {}, + wrapFetchWithPayment: () => (url: string, init?: any) => fetch(url, init), +})); +jest.mock('@x402/stellar', () => ({ + createEd25519Signer: () => ({ + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + signAuthEntry: async () => ({ signedAuthEntry: 'mock' }), + }), +})); +jest.mock('@x402/stellar/exact/client', () => ({ + ExactStellarScheme: class {}, +})); +jest.mock('mppx/client', () => ({ + Mppx: { create: () => ({ fetch: async () => new Response('{}') }) }, +})); +jest.mock('@stellar/mpp/charge/client', () => ({ + stellar: { charge: () => ({}) }, +})); +jest.mock('@stellar/stellar-sdk', () => ({ + Networks: { TESTNET: 'Test SDF Network ; September 2015', PUBLIC: 'Public Global Stellar Network ; September 2015' }, +})); + +let capturedRoutes: Record | undefined; + +jest.mock('@x402/express', () => ({ + paymentMiddlewareFromConfig: jest.fn((routes: Record) => { + capturedRoutes = routes; + return async (_req: any, _res: any, next: any) => next(); + }), +})); +jest.mock('@x402/core/server', () => ({ + HTTPFacilitatorClient: class {}, +})); +jest.mock('@x402/stellar/exact/server', () => ({ + ExactStellarScheme: class {}, +})); +// `@x402/extensions/bazaar`'s subpath export isn't resolvable under this +// package's `moduleResolution: "node"` tsconfig (confirmed: tsc reports +// types exist but can't be resolved in that mode) — so this mock, like the +// one above for `@x402/stellar/exact/server`, is referenced only as a +// string. `mockDeclareDiscoveryExtension` is captured via the +// `jest.mock`-hoisting convention (names starting with `mock` survive +// babel-plugin-jest-hoist's hoist-above-imports rewrite) instead of a +// static import of the real module. +const mockDeclareDiscoveryExtension = jest.fn((ext: Record) => ({ bazaar: ext })); +jest.mock('@x402/extensions/bazaar', () => ({ + declareDiscoveryExtension: mockDeclareDiscoveryExtension, +})); + +import { x402Serve } from './index'; + +const declareDiscoveryExtension = mockDeclareDiscoveryExtension; + +const BASE_CONFIG = { + payTo: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + facilitatorApiKey: 'oz_test_key', +}; + +function mockReqRes() { + const req: any = { method: 'GET', url: '/signals', originalUrl: '/signals', headers: {}, ip: '203.0.113.1' }; + const res: any = { + headersSent: false, + statusCode: 200, + status(code: number) { this.statusCode = code; return this; }, + json(body: any) { this.body = body; return this; }, + setHeader() {}, + }; + return { req, res }; +} + +describe('x402Serve — discovery extension', () => { + beforeEach(() => { + capturedRoutes = undefined; + jest.clearAllMocks(); + (globalThis as any).fetch = jest.fn(async () => ({ ok: true, status: 200 })); + }); + + it('attaches extensions.bazaar to a route that declares input/inputSchema/output', async () => { + const handler = x402Serve({ + ...BASE_CONFIG, + routes: { + 'GET /signals': { + price: '$0.02', + input: { count: 20 }, + inputSchema: { properties: { count: { type: 'integer', maximum: 100 } } }, + output: { example: { signals: [] } }, + }, + }, + }); + + const { req, res } = mockReqRes(); + await handler(req, res, () => {}); + + expect(declareDiscoveryExtension).toHaveBeenCalledTimes(1); + expect(declareDiscoveryExtension).toHaveBeenCalledWith({ + input: { count: 20 }, + inputSchema: { properties: { count: { type: 'integer', maximum: 100 } } }, + output: { example: { signals: [] } }, + }); + + expect(capturedRoutes).toBeDefined(); + expect(capturedRoutes!['GET /signals'].extensions).toEqual({ + bazaar: { + input: { count: 20 }, + inputSchema: { properties: { count: { type: 'integer', maximum: 100 } } }, + output: { example: { signals: [] } }, + }, + }); + }); + + it('declares only the fields actually provided, never `undefined` placeholders', async () => { + const handler = x402Serve({ + ...BASE_CONFIG, + routes: { + 'GET /market': { price: '$0.05', output: { example: { market: {} } } }, + }, + }); + + const { req, res } = mockReqRes(); + await handler(req, res, () => {}); + + expect(declareDiscoveryExtension).toHaveBeenCalledWith({ output: { example: { market: {} } } }); + expect(capturedRoutes!['GET /market'].extensions).toEqual({ + bazaar: { output: { example: { market: {} } } }, + }); + }); + + it('leaves a route with no discovery fields with no `extensions` key at all', async () => { + const handler = x402Serve({ + ...BASE_CONFIG, + routes: { 'GET /signals': '$0.02' }, + }); + + const { req, res } = mockReqRes(); + await handler(req, res, () => {}); + + expect(declareDiscoveryExtension).not.toHaveBeenCalled(); + expect(capturedRoutes).toBeDefined(); + expect(capturedRoutes!['GET /signals']).not.toHaveProperty('extensions'); + }); + + it('mixed config: only the route with discovery fields gets extensions', async () => { + const handler = x402Serve({ + ...BASE_CONFIG, + routes: { + 'GET /signals': '$0.02', + 'GET /market': { price: '$0.05', input: { pair: 'XLM/USDC' } }, + }, + }); + + const { req, res } = mockReqRes(); + await handler(req, res, () => {}); + + expect(declareDiscoveryExtension).toHaveBeenCalledTimes(1); + expect(capturedRoutes!['GET /signals']).not.toHaveProperty('extensions'); + expect(capturedRoutes!['GET /market'].extensions).toEqual({ + bazaar: { input: { pair: 'XLM/USDC' } }, + }); + }); +});