diff --git a/CHANGELOG.md b/CHANGELOG.md index 0119f1d..816479f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bazaar/challenge.ts b/src/bazaar/challenge.ts index 5efb06f..32aefb7 100644 --- a/src/bazaar/challenge.ts +++ b/src/bazaar/challenge.ts @@ -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 { @@ -132,26 +138,46 @@ 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; - 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; return { check: "challenge", status: "pass", @@ -159,6 +185,12 @@ export function checkChallenge( }; } +/** + * 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 diff --git a/src/bazaar/extensions-bazaar.ts b/src/bazaar/extensions-bazaar.ts new file mode 100644 index 0000000..c02b547 --- /dev/null +++ b/src/bazaar/extensions-bazaar.ts @@ -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; + 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; + 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; + 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): boolean { + const info = b["info"]; + if (typeof info !== "object" || info === null) return false; + const i = info as Record; + 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; +} diff --git a/src/bazaar/well-known.ts b/src/bazaar/well-known.ts index 20a7929..97f5bf0 100644 --- a/src/bazaar/well-known.ts +++ b/src/bazaar/well-known.ts @@ -12,13 +12,18 @@ * - 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 @@ -26,6 +31,7 @@ * the structural defects that crawlers definitely reject. */ +import { validateBazaarExtension } from "./extensions-bazaar.js"; import type { CheckResult, WellKnownManifest } from "./types.js"; export interface WellKnownFetcher { @@ -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`); + } } } } @@ -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, }; diff --git a/tests/unit/bazaar-challenge.test.ts b/tests/unit/bazaar-challenge.test.ts index fb977c6..31e0351 100644 --- a/tests/unit/bazaar-challenge.test.ts +++ b/tests/unit/bazaar-challenge.test.ts @@ -187,6 +187,106 @@ describe("checkChallenge", () => { }); }); +// ---- X402-43 (D.5) variant-aware extensions.bazaar ---- +// +// AsaiShota #72 + 0xdespot #2207 — body-discovery shape (info.input + +// info.output + schema) was false-positive `implementation_issue` in +// v0.3.0/0.3.1. These tests cover the variant-aware refactor. + +describe("checkChallenge — body-discovery variant (D.5)", () => { + function bodyDiscoveryChallenge(bazaarOverride: Record = {}): unknown { + return challengeBody({ + extensions: { + bazaar: { + info: { + input: { type: "object", properties: { q: { type: "string" } } }, + output: { type: "object", properties: { result: { type: "string" } } }, + }, + schema: { version: 1 }, + ...bazaarOverride, + }, + }, + }); + } + + it("passes on a well-formed body-discovery extensions.bazaar shape (AsaiShota #72 + 0xdespot regression guard)", () => { + const r = checkChallenge(SERVICE, { + ok: true, + requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: SERVICE, + payTo: PAY_TO, + asset: USDC, + maxTimeoutSeconds: 300, + }, + rawBody: bodyDiscoveryChallenge(), + }); + expect(r.status).toBe("pass"); + expect(r.message).toMatch(/body-discovery/); + }); + + it("fails when body-discovery is missing info.output", () => { + const r = checkChallenge(SERVICE, { + ok: true, + requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: SERVICE, + payTo: PAY_TO, + asset: USDC, + maxTimeoutSeconds: 300, + }, + rawBody: bodyDiscoveryChallenge({ info: { input: {}, output: null } }), + }); + expect(r.status).toBe("fail"); + expect(r.message).toMatch(/info\.output/); + expect(r.detail?.variant).toBe("body-discovery"); + }); + + it("fails when body-discovery is missing schema", () => { + const r = checkChallenge(SERVICE, { + ok: true, + requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: SERVICE, + payTo: PAY_TO, + asset: USDC, + maxTimeoutSeconds: 300, + }, + rawBody: bodyDiscoveryChallenge({ schema: "not an object" }), + }); + expect(r.status).toBe("fail"); + expect(r.message).toMatch(/schema/); + expect(r.detail?.variant).toBe("body-discovery"); + }); + + it("passes when body-discovery indicators are present even without top-level name/description (body wins over mcp default)", () => { + // The hybrid case: an object that has both shapes' fields. Body + // indicators take precedence per ADR-004 design — services that + // ship info.input/output/schema are body-discovery by design. + const r = checkChallenge(SERVICE, { + ok: true, + requirements: { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: SERVICE, + payTo: PAY_TO, + asset: USDC, + maxTimeoutSeconds: 300, + }, + rawBody: bodyDiscoveryChallenge(), // No name/description, just info+schema + }); + expect(r.status).toBe("pass"); + expect(r.message).toMatch(/body-discovery/); + }); +}); + describe("checkSelfPayment", () => { it("passes when no payerHint is supplied (silent default)", () => { const r = checkSelfPayment(PAY_TO); diff --git a/tests/unit/bazaar-extensions-bazaar.test.ts b/tests/unit/bazaar-extensions-bazaar.test.ts new file mode 100644 index 0000000..579122a --- /dev/null +++ b/tests/unit/bazaar-extensions-bazaar.test.ts @@ -0,0 +1,159 @@ +/** + * X402-43 (D.5) — variant-aware extensions.bazaar helper unit tests. + * + * Tests the variant detection + validation logic in isolation, before + * its callers (challenge.ts, well-known.ts) consume it. + */ +import { describe, expect, it } from "vitest"; +import { + detectBazaarVariant, + validateBazaarExtension, +} from "../../src/bazaar/extensions-bazaar.js"; + +describe("detectBazaarVariant", () => { + it("returns 'unknown' for non-object inputs", () => { + expect(detectBazaarVariant(undefined)).toBe("unknown"); + expect(detectBazaarVariant(null)).toBe("unknown"); + expect(detectBazaarVariant("not an object")).toBe("unknown"); + expect(detectBazaarVariant(42)).toBe("unknown"); + }); + + it("returns 'body-discovery' when info.input + info.output + schema all present", () => { + expect( + detectBazaarVariant({ + info: { input: { type: "object" }, output: { type: "object" } }, + schema: { fields: [] }, + }), + ).toBe("body-discovery"); + }); + + it("returns 'mcp-discovery' as the default fallback for objects without body-discovery shape (ADR-004 'else assume Mcp')", () => { + // Empty object — no body indicators, default to mcp + expect(detectBazaarVariant({})).toBe("mcp-discovery"); + // Mcp-shaped object + expect(detectBazaarVariant({ name: "X", description: "Y" })).toBe("mcp-discovery"); + // Object with foreign fields — still mcp by default + expect(detectBazaarVariant({ category: "data" })).toBe("mcp-discovery"); + }); + + it("returns 'body-discovery' even when name/description are also present (body indicators win)", () => { + expect( + detectBazaarVariant({ + name: "Hybrid", + description: "Both shapes present", + info: { input: {}, output: {} }, + schema: {}, + }), + ).toBe("body-discovery"); + }); + + it("returns 'mcp-discovery' when info is present but missing input or output or schema", () => { + // Missing schema + expect( + detectBazaarVariant({ + info: { input: {}, output: {} }, + }), + ).toBe("mcp-discovery"); + // Missing info.output + expect( + detectBazaarVariant({ + info: { input: {} }, + schema: {}, + }), + ).toBe("mcp-discovery"); + // info is not an object + expect(detectBazaarVariant({ info: "string", schema: {} })).toBe("mcp-discovery"); + }); +}); + +describe("validateBazaarExtension — mcp-discovery variant", () => { + it("passes on a well-formed mcp-discovery bazaar object", () => { + const r = validateBazaarExtension({ name: "Catalog API", description: "Esim providers" }); + expect(r.variant).toBe("mcp-discovery"); + expect(r.ok).toBe(true); + expect(r.missing).toEqual([]); + }); + + it("fails with both fields missing when bazaar is an empty object", () => { + const r = validateBazaarExtension({}); + expect(r.variant).toBe("mcp-discovery"); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(expect.arrayContaining(["name", "description"])); + expect(r.missing).toHaveLength(2); + }); + + it("fails when name is missing or whitespace-only", () => { + const r = validateBazaarExtension({ name: " ", description: "ok" }); + expect(r.variant).toBe("mcp-discovery"); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(["name"]); + }); +}); + +describe("validateBazaarExtension — body-discovery variant", () => { + function bodyDiscovery(overrides: Record = {}): unknown { + return { + info: { + input: { type: "object", properties: { q: { type: "string" } } }, + output: { type: "object", properties: { result: { type: "string" } } }, + }, + schema: { version: 1 }, + ...overrides, + }; + } + + it("passes on a well-formed body-discovery bazaar object", () => { + const r = validateBazaarExtension(bodyDiscovery()); + expect(r.variant).toBe("body-discovery"); + expect(r.ok).toBe(true); + expect(r.missing).toEqual([]); + }); + + it("fails when info.output is missing", () => { + const r = validateBazaarExtension({ + info: { input: {} }, + schema: {}, + }); + // This case has no info.output and no schema field shape match -> body? No, + // missing schema means detection falls back to mcp. Verify: + expect(r.variant).toBe("mcp-discovery"); + // Now test a real body-discovery case where info exists but output is not an object + const r2 = validateBazaarExtension({ + info: { input: {}, output: "not-object" }, + schema: {}, + }); + expect(r2.variant).toBe("body-discovery"); + expect(r2.ok).toBe(false); + expect(r2.missing).toEqual(["info.output"]); + }); + + it("fails when schema is not an object", () => { + const r = validateBazaarExtension({ + info: { input: {}, output: {} }, + schema: "not an object but the key is present", + }); + expect(r.variant).toBe("body-discovery"); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(["schema"]); + }); + + it("reports multiple body-discovery missing fields in one pass", () => { + const r = validateBazaarExtension({ + info: { input: null, output: null }, + schema: 42, + }); + expect(r.variant).toBe("body-discovery"); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(expect.arrayContaining(["info.input", "info.output", "schema"])); + expect(r.missing).toHaveLength(3); + }); +}); + +describe("validateBazaarExtension — unknown variant (defensive)", () => { + it("returns ok=true and skips validation for non-object inputs", () => { + const r = validateBazaarExtension(undefined); + expect(r.variant).toBe("unknown"); + expect(r.ok).toBe(true); + expect(r.missing).toEqual([]); + }); +}); diff --git a/tests/unit/bazaar-well-known.test.ts b/tests/unit/bazaar-well-known.test.ts index 34f70c6..026cad0 100644 --- a/tests/unit/bazaar-well-known.test.ts +++ b/tests/unit/bazaar-well-known.test.ts @@ -176,4 +176,82 @@ describe("checkWellKnown", () => { const { result } = await checkWellKnown(SERVICE, fetcher); expect(result.status).toBe("pass"); }); + + // ---- X402-43 (D.5) variant-aware extensions.bazaar ---- + // + // The manifest D.1 hygiene check from PR #70 carried the same + // McpDiscoveryExtension assumption that challenge.ts had — + // false-positive `implementation_issue` on body-discovery services. + // AsaiShota #72 + 0xdespot #2207 corroboration. These tests cover + // the variant-aware refactor for the well-known surface. + + describe("body-discovery variant", () => { + function bodyDiscoveryManifest(bazaarOverride: Record = {}): unknown { + return { + name: "X", + description: "Y", + extensions: { + bazaar: { + info: { + input: { type: "object", properties: { q: { type: "string" } } }, + output: { type: "object", properties: { result: { type: "string" } } }, + }, + schema: { version: 1 }, + ...bazaarOverride, + }, + }, + }; + } + + it("passes on a well-formed body-discovery bazaar extension", async () => { + const fetcher = mockFetch(() => jsonResponse(bodyDiscoveryManifest())); + const { result } = await checkWellKnown(SERVICE, fetcher); + expect(result.status).toBe("pass"); + expect(result.message).toMatch(/body-discovery variant/); + }); + + it("fails when body-discovery is missing info.input", async () => { + const fetcher = mockFetch(() => + jsonResponse(bodyDiscoveryManifest({ info: { input: null, output: {} } })), + ); + const { result } = await checkWellKnown(SERVICE, fetcher); + expect(result.status).toBe("fail"); + expect(result.detail?.issues).toEqual( + expect.arrayContaining([expect.stringMatching(/extensions\.bazaar\.info\.input/)]), + ); + }); + + it("fails when body-discovery is missing schema (whole schema key non-object)", async () => { + const fetcher = mockFetch(() => + jsonResponse(bodyDiscoveryManifest({ schema: "not an object" })), + ); + const { result } = await checkWellKnown(SERVICE, fetcher); + expect(result.status).toBe("fail"); + expect(result.detail?.issues).toEqual( + expect.arrayContaining([expect.stringMatching(/extensions\.bazaar\.schema/)]), + ); + }); + + it("passes when discovery_extension: bazaar is declared and body-discovery shape is correct", async () => { + const fetcher = mockFetch(() => + jsonResponse({ + name: "X", + description: "Y", + discovery_extension: "bazaar", + extensions: { + bazaar: { + info: { + input: { type: "object" }, + output: { type: "object" }, + }, + schema: { version: 1 }, + }, + }, + }), + ); + const { result } = await checkWellKnown(SERVICE, fetcher); + expect(result.status).toBe("pass"); + expect(result.message).toMatch(/body-discovery variant/); + }); + }); });