Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`bazaar-check --endpoint <paid-url>` 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/[email protected]` 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 <url> 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

Expand Down
39 changes: 33 additions & 6 deletions src/bazaar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BazaarReport> {
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) {
Expand Down
26 changes: 26 additions & 0 deletions src/cli/bazaar-check-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -54,6 +62,14 @@ export async function runBazaarCheckCommand(
ctx.stderr.write(`error: <service-url> 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";
Expand Down Expand Up @@ -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,
});

Expand Down
6 changes: 6 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface BazaarCheckFlags {
payerHint?: string;
discoveryBaseUrl?: string;
timeoutMs?: string;
endpoint?: string;
}

interface VersionsFlags {
Expand Down Expand Up @@ -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 <ms>", "Per-request timeout for bazaar-check HTTP probes (default 10000)")
.option(
"--endpoint <paid-url>",
"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;
Expand All @@ -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 },
);
Expand Down
198 changes: 198 additions & 0 deletions tests/integration/bazaar-check-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paid-url>` 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<typeof fetch>[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();
});
});
});