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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### 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).

## [0.3.1] — 2026-05-20

Expand Down
70 changes: 51 additions & 19 deletions src/bazaar/challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@
* - Body parses as JSON.
* - `accepts[0]` exists and decodes as `PaymentRequirements`.
* - `extensions.bazaar` is present (when caller signals
* `expectBazaar: true`) and has `name` + `description` strings.
* `expectBazaar: true`) and conforms to its detected variant —
* either the MCP-style (`name` + `description`) or Body-style
* (`info.input` + `info.output` + `schema`) discovery extension
* shape per `@x402/extensions@2.11.0`. See
* [extensions-bazaar.ts](./extensions-bazaar.ts) for the variant rules
* and ADR-004 for the design.
*
* Why bazaar metadata matters: the listing UI on agentic.market shows
* `extensions.bazaar.name` and `extensions.bazaar.description` per
* route. Without them, the listing falls back to raw URLs (TheRoosters,
* GM in Discord transcript). With them mangled, the listing looks
* unprofessional and operators have to file support tickets to fix.
* `extensions.bazaar.name` / `description` (MCP variant) or surfaces
* `info.input/output/schema` (Body variant) per route. Without them,
* the listing falls back to raw URLs or fails to render at all
* (TheRoosters, GM, AsaiShota, 0xdespot all hit this in v0.3.0/0.3.1).
*/

import { parseChallengeBody } from "../decoder/parse.js";
import { detectBazaarVariant, validateBazaarExtension } from "./extensions-bazaar.js";
import type { CheckResult, ChallengeFetchResult } from "./types.js";

export interface ChallengeFetchOptions {
Expand Down Expand Up @@ -132,33 +138,59 @@ export function checkChallenge(
fix: `populate extensions.bazaar = { name: "Your service", description: "What it does" }. Without this, agentic.market falls back to raw URLs.`,
};
}
const bazaarObj = bazaar as Record<string, unknown>;
const missing: string[] = [];
if (typeof bazaarObj["name"] !== "string" || (bazaarObj["name"] as string).trim() === "") {
missing.push("name");
}
if (
typeof bazaarObj["description"] !== "string" ||
(bazaarObj["description"] as string).trim() === ""
) {
missing.push("description");
const validation = validateBazaarExtension(bazaar);
if (validation.variant === "unknown") {
// Defensive — variant detection couldn't classify the shape (future
// @x402/extensions variants land here). Skip validation, pass with
// an info-style message. Caller upstream already screened for
// non-object so this branch is unreachable from production today;
// kept for forward-compat per ADR-004 risk register.
return {
check: "challenge",
status: "pass",
message: `${serviceUrl}: extensions.bazaar variant not recognised; skipping shape validation`,
};
}
if (missing.length > 0) {
if (!validation.ok) {
if (validation.variant === "mcp-discovery") {
return {
check: "challenge",
status: "fail",
message: `${serviceUrl}: extensions.bazaar is missing required fields: ${validation.missing.join(", ")}`,
fix: `every Bazaar listing needs extensions.bazaar.name (string, non-empty) and extensions.bazaar.description (string, non-empty). TheRoosters + GM in Discord hit this — fix it and the listing renders correctly.`,
detail: { missingFields: validation.missing, variant: validation.variant },
};
}
// body-discovery variant
return {
check: "challenge",
status: "fail",
message: `${serviceUrl}: extensions.bazaar is missing required fields: ${missing.join(", ")}`,
fix: `every Bazaar listing needs extensions.bazaar.name (string, non-empty) and extensions.bazaar.description (string, non-empty). TheRoosters + GM in Discord hit this — fix it and the listing renders correctly.`,
detail: { missingFields: missing },
message: `${serviceUrl}: extensions.bazaar (body-discovery variant) is missing required fields: ${validation.missing.join(", ")}`,
fix: `body-discovery services need extensions.bazaar.info.input, extensions.bazaar.info.output, and extensions.bazaar.schema (each a non-null object). See @x402/extensions@2.11.0 declareDiscoveryExtension(...).`,
detail: { missingFields: validation.missing, variant: validation.variant },
};
}
if (validation.variant === "body-discovery") {
return {
check: "challenge",
status: "pass",
message: `extensions.bazaar present (body-discovery variant: info.input/output + schema set)`,
};
}
const bazaarObj = bazaar as Record<string, unknown>;
return {
check: "challenge",
status: "pass",
message: `extensions.bazaar present (name="${bazaarObj["name"]}", description set)`,
};
}

/**
* Re-export for callers/tests that need direct access to the variant
* detector. Single import surface.
*/
export { detectBazaarVariant };

/**
* Self-payment guard: `payer == payTo` is rejected by some facilitators
* (notably CDP). Reuses [X402-33]'s selfPaymentRule semantics inline
Expand Down
100 changes: 100 additions & 0 deletions src/bazaar/extensions-bazaar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* X402-43 (D.5) — variant-aware `extensions.bazaar` validation.
*
* `@x402/extensions@2.11.0` ships TWO discovery-extension variants. Up
* through v0.3.1 we assumed only the MCP-style shape (top-level `name`
* + `description`), which produced false-positive `implementation_issue`
* verdicts against API-style services that legitimately use the
* Body-discovery shape (`info.input` + `info.output` + `schema`). This
* was AsaiShota's [#72](https://github.com/fardinvahdat/x402trace/issues/72)
* + corroborated by @0xdespot on
* [x402-foundation/x402#2207](https://github.com/x402-foundation/x402/issues/2207).
*
* Variant rules:
*
* - **body-discovery** — `info.input` + `info.output` + `schema` all
* present at the top level of `extensions.bazaar`. Validated by
* requiring each of those three to be a non-null object.
* - **mcp-discovery** — default fallback per ADR-004's "else assume
* Mcp" rule. Validated by requiring top-level `name` + `description`
* as non-empty strings. Preserves the v0.3.0/0.3.1 behavior — the
* TheRoosters / GM / hypeprinter007-stack #65 pain set.
* - **unknown** — defensive third state for future `@x402/extensions`
* additions. Fires only when the argument is not an object at all;
* in that case validation is a no-op (skip). Callers already
* pre-screen for non-object before reaching this helper, so in
* practice this branch is unreachable from production code — but
* it's documented so future variants land here cleanly (per the
* ADR-004 risk register).
*
* Helper stays presentation-free: it returns the detected variant + the
* per-field missing list. Each caller (challenge.ts + well-known.ts)
* formats the user-facing message in its own voice.
*/

export type BazaarVariant = "mcp-discovery" | "body-discovery" | "unknown";

export interface BazaarValidationResult {
readonly variant: BazaarVariant;
/** True when no per-variant required field is missing. */
readonly ok: boolean;
/** Per-variant required fields that are missing or malformed. */
readonly missing: readonly string[];
}

/**
* Identify which discovery-extension variant `extensions.bazaar` is.
*
* Rule order is intentional: body-discovery wins when its indicators
* (info.input + info.output + schema) are all present. Anything else
* that is an object falls through to mcp-discovery per ADR-004's "else
* assume Mcp" decision.
*/
export function detectBazaarVariant(bazaar: unknown): BazaarVariant {
if (typeof bazaar !== "object" || bazaar === null) return "unknown";
const b = bazaar as Record<string, unknown>;
if (isBodyDiscoveryShape(b)) return "body-discovery";
return "mcp-discovery";
}

/**
* Validate `extensions.bazaar` per its detected variant. Returns the
* variant plus the names of any required fields that are missing or
* malformed for that variant.
*/
export function validateBazaarExtension(bazaar: unknown): BazaarValidationResult {
const variant = detectBazaarVariant(bazaar);
if (variant === "unknown") {
// Non-object input: caller already pre-screened. Return ok=true so
// upstream "unknown variant; skip validation" semantics hold.
return { variant, ok: true, missing: [] };
}
const b = bazaar as Record<string, unknown>;
const missing: string[] = [];
if (variant === "mcp-discovery") {
if (!isNonEmptyString(b["name"])) missing.push("name");
if (!isNonEmptyString(b["description"])) missing.push("description");
} else {
// body-discovery — detect path above guarantees info is an object
const info = b["info"] as Record<string, unknown>;
if (!isObject(info["input"])) missing.push("info.input");
if (!isObject(info["output"])) missing.push("info.output");
if (!isObject(b["schema"])) missing.push("schema");
}
return { variant, ok: missing.length === 0, missing };
}

function isBodyDiscoveryShape(b: Record<string, unknown>): boolean {
const info = b["info"];
if (typeof info !== "object" || info === null) return false;
const i = info as Record<string, unknown>;
return "input" in i && "output" in i && "schema" in b;
}

function isNonEmptyString(v: unknown): boolean {
return typeof v === "string" && v.trim() !== "";
}

function isObject(v: unknown): boolean {
return typeof v === "object" && v !== null;
}
45 changes: 32 additions & 13 deletions src/bazaar/well-known.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,26 @@
* - Top-level `name` (string, non-empty) is present.
* - Top-level `description` (string, non-empty) is present.
* - `accepts` (array, optional) — if present, each entry is an object.
* - `extensions.bazaar.{name, description}` (string, non-empty) when
* bazaar discovery is opted into — either via `discovery_extension:
* "bazaar"` or by including `extensions.bazaar` at all. Empty string
* and whitespace-only are treated as missing: the mapper's listing
* UI renders them as blank cards, which is the failure mode that
* keeps reaching Discord (TheRoosters, GM, and the Max/Zev case
* surfaced by hypeprinter007-stack in #65 finding 2).
* - `extensions.bazaar` conforms to its detected variant when bazaar
* discovery is opted into — either via `discovery_extension:
* "bazaar"` or by including `extensions.bazaar` at all. Two variants
* are supported per `@x402/extensions@2.11.0`:
* - **MCP-discovery:** `extensions.bazaar.{name, description}` as
* non-empty strings (TheRoosters, GM, hypeprinter007 #65 pain
* set — empty strings render as blank listing cards).
* - **Body-discovery:** `extensions.bazaar.{info.input, info.output,
* schema}` as non-null objects (AsaiShota #72 + 0xdespot #2207
* pain set — was false-positive in v0.3.0/0.3.1).
* See [extensions-bazaar.ts](./extensions-bazaar.ts) for the variant
* detection rule and ADR-004 for the design rationale.
*
* Intentionally lenient on the rest of the spec. The schema is actively
* evolving (per ADR-003 risk register); strict validation would
* false-positive on operators ahead of the spec curve. We fail only on
* the structural defects that crawlers definitely reject.
*/

import { validateBazaarExtension } from "./extensions-bazaar.js";
import type { CheckResult, WellKnownManifest } from "./types.js";

export interface WellKnownFetcher {
Expand Down Expand Up @@ -121,17 +127,23 @@ export async function checkWellKnown(

const bazaarExt = readBazaarExtension(manifest);
const declaresBazaar = manifest.discovery_extension === "bazaar";
let bazaarVariant: "mcp-discovery" | "body-discovery" | "unknown" | undefined = undefined;
if (declaresBazaar || bazaarExt !== undefined) {
if (bazaarExt === undefined) {
issues.push(
'`discovery_extension: "bazaar"` declared but `extensions.bazaar` is missing or not an object',
);
} else {
if (typeof bazaarExt.name !== "string" || bazaarExt.name.trim() === "") {
issues.push("missing or empty `extensions.bazaar.name` field");
}
if (typeof bazaarExt.description !== "string" || bazaarExt.description.trim() === "") {
issues.push("missing or empty `extensions.bazaar.description` field");
const validation = validateBazaarExtension(bazaarExt);
bazaarVariant = validation.variant;
if (validation.variant === "unknown") {
// Defensive forward-compat — variant unrecognised. Skip
// shape validation; caller already screened for object-ness
// via readBazaarExtension above. See ADR-004 risk register.
} else if (!validation.ok) {
for (const field of validation.missing) {
issues.push(`missing or empty \`extensions.bazaar.${field}\` field`);
}
}
}
}
Expand All @@ -149,11 +161,18 @@ export async function checkWellKnown(
};
}

const bazaarSummary = bazaarExt
? bazaarVariant === "body-discovery"
? `, extensions.bazaar populated (body-discovery variant)`
: bazaarVariant === "unknown"
? `, extensions.bazaar present (variant unrecognised; shape validation skipped)`
: `, extensions.bazaar populated`
: "";
return {
result: {
check: "well-known",
status: "pass",
message: `manifest at ${url} is well-formed (name="${manifest.name}", description set, accepts ${Array.isArray(manifest.accepts) ? `[${manifest.accepts.length}]` : "absent"}${bazaarExt ? `, extensions.bazaar populated` : ""})`,
message: `manifest at ${url} is well-formed (name="${manifest.name}", description set, accepts ${Array.isArray(manifest.accepts) ? `[${manifest.accepts.length}]` : "absent"}${bazaarSummary})`,
},
manifest,
};
Expand Down
Loading