diff --git a/CHANGELOG.md b/CHANGELOG.md index 816479f..539c551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,20 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`bazaar-check --endpoint ` per-route 402 probe mode** ([X402-42](https://vahdatfardin.atlassian.net/browse/X402-42), D.4). For services that publish per-route instead of at root `/.well-known/x402` — the [#2207](https://github.com/x402-foundation/x402/issues/2207) pattern documented by @AsaiShota (test-echo-cdp), @evanatpizzarobot (TensorFeed), and @0xdespot (hyperD.ai). When `--endpoint` is supplied, the bazaar-check pipeline skips the root well-known probe entirely and fetches the 402 challenge directly from the given paid URL; manifest-shape validations run against the 402 body instead of the root manifest. Self-payment guard + CDP indexing query continue to run unchanged (they consume the challenge body's `payTo` regardless of how the challenge was obtained). UX: a one-line info note prints when `--endpoint` is used (`ℹ skipping root /.well-known/x402 probe per --endpoint. Note: services that DO publish at root signal extra discoverability hygiene; consider both.`); routes to stdout in `--log human` and stderr in `--log json` so stdout stays JSON-parseable. New `endpoint?: string` field on `BazaarCheckOptions` (programmatic API); new `endpoint?: string` field on `BazaarCheckCommandOptions`. URL-validated at command entry; non-URL values exit 1 with a clear error. + ### Fixed - **`bazaar-check` false-positive `implementation_issue` on body-discovery services** ([#72](https://github.com/fardinvahdat/x402trace/issues/72) → [X402-43](https://vahdatfardin.atlassian.net/browse/X402-43), thanks @AsaiShota 🙏 for the diagnosis with `@x402/extensions@2.11.0` package-source citation, and @0xdespot 🙏 for the second-voice corroboration on [x402-foundation/x402#2207](https://github.com/x402-foundation/x402/issues/2207)). v0.3.0/0.3.1's `src/bazaar/challenge.ts` and `src/bazaar/well-known.ts` both validated `extensions.bazaar` assuming the **MCP-discovery** shape (top-level `name` + `description`). Real-world v2 services using the **Body-discovery** shape (`info.input` + `info.output` + `schema` per `declareDiscoveryExtension(...)`) failed the envelope check despite being correctly implemented — verdict overrode the substantive indexing-probe signal. New `src/bazaar/extensions-bazaar.ts` helper detects the variant by shape (body-discovery indicators win; else assume mcp-discovery per ADR-004) and validates the variant-appropriate required-field set. Default-deny third branch for future `@x402/extensions` variants we don't recognise yet — skips shape validation with an informational pass rather than false-positive. Tests cover ≥4 cases per affected file (challenge.ts + well-known.ts) plus 13 helper unit tests. ### JSON API -No changes to the `--log json` envelope shape this session. The `bazaar-check` JSON output now includes a `detail.variant` field on extensions.bazaar-related check results (additive); existing fields and shapes are preserved per the JSON API stability commitment (ADR-004). +Two additive changes to the `--log json` output this cycle, both preserving the existing envelope shape per ADR-004 Pillar 2: + +- The `bazaar-check` JSON output now includes a `detail.variant` field on extensions.bazaar-related check results from the D.5 variant-aware refactor (value: `"mcp-discovery"`, `"body-discovery"`, or `"unknown"`). +- When `--endpoint` is supplied, the `well-known` result shape is preserved with a different `message`: `{ check: "well-known", status: "pass", message: "skipped per --endpoint (probing directly instead of /.well-known/x402)" }`. The four-result envelope shape is preserved; the well-known slot's `message` field is the only carrier of the skip signal. ## [0.3.1] — 2026-05-20 diff --git a/src/bazaar/index.ts b/src/bazaar/index.ts index 40bcb59..5b4ceae 100644 --- a/src/bazaar/index.ts +++ b/src/bazaar/index.ts @@ -47,24 +47,51 @@ export interface BazaarCheckOptions { readonly discoveryBaseUrl?: string; /** Inject a custom fetch implementation across all HTTP calls. */ readonly fetcher?: typeof fetch; + /** + * X402-42 (D.4) — when set, skip the root `/.well-known/x402` probe + * entirely and probe `endpoint` directly for the 402 challenge + * instead. For services that only publish per-route (TensorFeed + * shape, AsaiShota's test-echo-cdp, 0xdespot's hyperD) or for + * validating one specific paid route. The default mode (probe root + * well-known) stays — services that publish at root signal extra + * discoverability hygiene worth preserving. + */ + readonly endpoint?: string; } /** * Run all four bazaar checks and return an aggregated report. Pure * orchestration — never throws on individual check failure (each * check captures its own error into a CheckResult). + * + * When `endpoint` is supplied (X402-42 / D.4), the root + * `/.well-known/x402` probe is skipped and the challenge is fetched + * from `endpoint` directly. Self-payment + indexing checks behave + * identically — they consume the challenge body's payTo regardless + * of how the challenge was obtained. */ export async function runBazaarCheck(opts: BazaarCheckOptions): Promise { const fetcher = opts.fetcher ?? fetch; const results: CheckResult[] = []; - // 1. Well-known manifest - const wk = await checkWellKnown(opts.serviceUrl, fetcher); - results.push(wk.result); + const useEndpointMode = opts.endpoint !== undefined; + const challengeUrl = opts.endpoint ?? opts.serviceUrl; + + // 1. Well-known manifest (skipped under --endpoint mode) + if (useEndpointMode) { + results.push({ + check: "well-known", + status: "pass", + message: `skipped per --endpoint (probing ${opts.endpoint} directly instead of /.well-known/x402)`, + }); + } else { + const wk = await checkWellKnown(opts.serviceUrl, fetcher); + results.push(wk.result); + } - // 2. 402 challenge structure - const challengeFetch = await fetchChallenge(opts.serviceUrl, fetcher); - results.push(checkChallenge(opts.serviceUrl, challengeFetch, { expectBazaar: true })); + // 2. 402 challenge structure (uses endpoint URL when set) + const challengeFetch = await fetchChallenge(challengeUrl, fetcher); + results.push(checkChallenge(challengeUrl, challengeFetch, { expectBazaar: true })); // 3. Self-payment guard (uses payerHint if supplied; otherwise pass) if (challengeFetch.ok) { diff --git a/src/cli/bazaar-check-command.ts b/src/cli/bazaar-check-command.ts index e7540d7..0609e4e 100644 --- a/src/cli/bazaar-check-command.ts +++ b/src/cli/bazaar-check-command.ts @@ -28,6 +28,14 @@ export interface BazaarCheckCommandOptions { readonly discoveryBaseUrl?: string; /** Per-request timeout for bazaar-check HTTP probes. */ readonly timeoutMs?: number; + /** + * X402-42 (D.4) — per-route 402 probe mode. When set, skips the root + * `/.well-known/x402` probe and fetches the 402 challenge directly + * from this paid endpoint URL instead. For services that only + * publish per-route (TensorFeed shape) or for validating one + * specific paid resource. + */ + readonly endpoint?: string; } export interface BazaarCheckRunContext { @@ -54,6 +62,14 @@ export async function runBazaarCheckCommand( ctx.stderr.write(`error: must be a valid URL (got '${service}')\n`); return EXIT_USAGE; } + if (opts.endpoint !== undefined) { + try { + new URL(opts.endpoint); + } catch { + ctx.stderr.write(`error: --endpoint must be a valid URL (got '${opts.endpoint}')\n`); + return EXIT_USAGE; + } + } const chainKey: "base-sepolia" | "base" = opts.chain ?? parseChainOrUndefined(ctx.env.BASE_CHAIN_ID) ?? "base-sepolia"; @@ -85,11 +101,21 @@ export async function runBazaarCheckCommand( const fetcher = withRequestTimeout(ctx.fetcher ?? fetch, timeoutMs); + // X402-42 (D.4) — per-route probe mode UX note. + if (opts.endpoint !== undefined) { + const note = + "ℹ skipping root /.well-known/x402 probe per --endpoint. " + + "Note: services that DO publish at root signal extra discoverability hygiene; consider both."; + if (format === "human") ctx.stdout.write(`${note}\n`); + else ctx.stderr.write(`${note}\n`); + } + const report = await runBazaarCheck({ serviceUrl: service, chain: chainKey, ...(opts.payerHint !== undefined ? { payerHint: opts.payerHint } : {}), ...(opts.discoveryBaseUrl !== undefined ? { discoveryBaseUrl: opts.discoveryBaseUrl } : {}), + ...(opts.endpoint !== undefined ? { endpoint: opts.endpoint } : {}), fetcher, }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 50ec1d1..4bca781 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -82,6 +82,7 @@ interface BazaarCheckFlags { payerHint?: string; discoveryBaseUrl?: string; timeoutMs?: string; + endpoint?: string; } interface VersionsFlags { @@ -305,6 +306,10 @@ export async function runCli(argv: readonly string[], ctx: CliContext): Promise< "Override the CDP discovery base URL (default https://api.cdp.coinbase.com)", ) .option("--timeout-ms ", "Per-request timeout for bazaar-check HTTP probes (default 10000)") + .option( + "--endpoint ", + "Per-route 402 probe mode (D.4): skip root /.well-known/x402 and fetch the 402 challenge from this paid URL instead. For services that only publish per-route.", + ) .action(async (service: string, flags: BazaarCheckFlags) => { const log = flags.log as LogFormat | undefined; const chain = flags.chain as "base-sepolia" | "base" | undefined; @@ -318,6 +323,7 @@ export async function runCli(argv: readonly string[], ctx: CliContext): Promise< ? { discoveryBaseUrl: flags.discoveryBaseUrl } : {}), ...(flags.timeoutMs !== undefined ? { timeoutMs: Number(flags.timeoutMs) } : {}), + ...(flags.endpoint !== undefined ? { endpoint: flags.endpoint } : {}), }, { stdout: ctx.stdout, stderr: ctx.stderr, env: ctx.env }, ); diff --git a/tests/integration/bazaar-check-pipeline.test.ts b/tests/integration/bazaar-check-pipeline.test.ts index f1d0ca7..76ec449 100644 --- a/tests/integration/bazaar-check-pipeline.test.ts +++ b/tests/integration/bazaar-check-pipeline.test.ts @@ -340,4 +340,202 @@ describe("bazaar-check pipeline (hermetic)", () => { const out = JSON.parse(stdout.buf.join("")); expect(out.verdict.failedChecks).toContain("self-payment"); }); + + // ---- X402-42 (D.4) — `--endpoint ` per-route probe mode ---- + // + // AsaiShota + evanatpizzarobot + 0xdespot all publish per-route on + // #2207 — root /.well-known/x402 returns 404 even though the per-route + // 402 challenge is well-formed. D.4 adds a flag to bypass the root + // probe and read the challenge directly from a paid endpoint. + + describe("--endpoint mode (D.4)", () => { + const ENDPOINT = "https://api.example.test/api/premium/route"; + + /** + * Per-route fetcher: well-known URL is unreachable (404); challenge + * available at the endpoint URL with a well-formed body. + */ + function endpointModeFetcher( + overrides: { + challenge?: () => Response; + discovery?: () => Response; + } = {}, + ): typeof fetch { + return ((urlInput: string) => { + const url = String(urlInput); + if (url.endsWith("/.well-known/x402")) { + // Per-route services: root returns 404. Test asserts the + // bazaar-check pipeline does NOT call this URL under --endpoint. + return Promise.resolve(new Response("not found", { status: 404 })); + } + if (url.includes("discovery/resources")) { + return Promise.resolve( + overrides.discovery?.() ?? jsonResponse({ resources: [{ id: "r1" }] }), + ); + } + // Challenge — endpoint URL OR (under default mode) service URL + return Promise.resolve( + overrides.challenge?.() ?? + new Response(JSON.stringify(challengeBody()), { + status: 402, + headers: { "content-type": "application/json" }, + }), + ); + }) as typeof fetch; + } + + it("happy path: skips well-known, fetches challenge from endpoint, returns looks_correct", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + const fetchedUrls: string[] = []; + const fetcher = ((urlInput: string) => { + fetchedUrls.push(String(urlInput)); + return endpointModeFetcher()(urlInput as unknown as Parameters[0]); + }) as typeof fetch; + + const code = await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: ENDPOINT, + log: "json", + chain: "base-sepolia", + }, + { stdout: stdout.stream, stderr: stderr.stream, env: {}, fetcher }, + ); + + expect(code).toBe(0); + const out = JSON.parse(stdout.buf.join("")); + expect(out.verdict.kind).toBe("looks_correct"); + + // well-known result is present but marked as skipped (status: pass) + const wk = out.results.find((r: { check: string }) => r.check === "well-known"); + expect(wk.status).toBe("pass"); + expect(wk.message).toMatch(/skipped per --endpoint/); + + // Critical: well-known URL was NEVER fetched + expect(fetchedUrls.some((u) => u.endsWith("/.well-known/x402"))).toBe(false); + + // Critical: the endpoint URL WAS fetched (used as challenge URL) + expect(fetchedUrls).toContain(ENDPOINT); + }); + + it("returns implementation_issue when the endpoint returns 402 with a malformed challenge body", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + const code = await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: ENDPOINT, + log: "json", + chain: "base-sepolia", + }, + { + stdout: stdout.stream, + stderr: stderr.stream, + env: {}, + fetcher: endpointModeFetcher({ + // Challenge body missing extensions.bazaar + challenge: () => + new Response(JSON.stringify(challengeBody({ extensions: undefined })), { + status: 402, + headers: { "content-type": "application/json" }, + }), + }), + }, + ); + + expect(code).toBe(2); + const out = JSON.parse(stdout.buf.join("")); + expect(out.verdict.kind).toBe("implementation_issue"); + expect(out.verdict.failedChecks).toContain("challenge"); + // well-known still skipped (no false-positive on missing root manifest) + expect(out.verdict.failedChecks).not.toContain("well-known"); + }); + + it("returns implementation_issue when the endpoint returns 2xx (no 402 challenge)", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + const code = await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: ENDPOINT, + log: "json", + chain: "base-sepolia", + }, + { + stdout: stdout.stream, + stderr: stderr.stream, + env: {}, + fetcher: endpointModeFetcher({ + challenge: () => new Response('{"ok":true}', { status: 200 }), + }), + }, + ); + + expect(code).toBe(2); + const out = JSON.parse(stdout.buf.join("")); + expect(out.verdict.failedChecks).toContain("challenge"); + const challenge = out.results.find((r: { check: string }) => r.check === "challenge"); + expect(challenge.message).toMatch(/expected HTTP 402/); + expect(challenge.message).toMatch(/200/); + }); + + it("rejects an invalid --endpoint value with exit code 1", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + const code = await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: "not a valid url", + log: "json", + chain: "base-sepolia", + }, + { stdout: stdout.stream, stderr: stderr.stream, env: {}, fetcher: endpointModeFetcher() }, + ); + + expect(code).toBe(1); + expect(stderr.buf.join("")).toMatch(/--endpoint must be a valid URL/); + }); + + it("prints the UX note to stdout in human format", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: ENDPOINT, + log: "human", + chain: "base-sepolia", + }, + { stdout: stdout.stream, stderr: stderr.stream, env: {}, fetcher: endpointModeFetcher() }, + ); + + const stdoutText = stdout.buf.join(""); + expect(stdoutText).toMatch(/skipping root \/\.well-known\/x402 probe per --endpoint/); + expect(stdoutText).toMatch(/services that DO publish at root signal/); + // stderr should NOT contain the note in human format + expect(stderr.buf.join("")).not.toMatch(/skipping root \/\.well-known/); + }); + + it("prints the UX note to stderr in json format (keeps stdout JSON-parseable)", async () => { + const stdout = captureStream(); + const stderr = captureStream(); + await runBazaarCheckCommand( + { + service: SERVICE, + endpoint: ENDPOINT, + log: "json", + chain: "base-sepolia", + }, + { stdout: stdout.stream, stderr: stderr.stream, env: {}, fetcher: endpointModeFetcher() }, + ); + + expect(stderr.buf.join("")).toMatch( + /skipping root \/\.well-known\/x402 probe per --endpoint/, + ); + // stdout must remain valid JSON (no info-note pollution) + const stdoutText = stdout.buf.join(""); + expect(() => JSON.parse(stdoutText)).not.toThrow(); + }); + }); });