diff --git a/CHANGELOG.md b/CHANGELOG.md index 539c551..3c5fed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`bazaar-check --log json` is now a documented public API contract** ([X402-44](https://vahdatfardin.atlassian.net/browse/X402-44), ADR-004 Pillar 2). Three deliverables landed together: the contract doc at [`src/bazaar/json-api.md`](./src/bazaar/json-api.md) (envelope shape + per-check `detail` keys + verdict discriminator + stability rules + regeneration workflow), a frozen exemplar at [`tests/fixtures/bazaar/json-api-snapshot.json`](./tests/fixtures/bazaar/json-api-snapshot.json), and a snapshot test at [`tests/integration/bazaar-check-json-api.test.ts`](./tests/integration/bazaar-check-json-api.test.ts) (6 tests catch field renames, removals, additions, and reordering against the exemplar). **Versioning rule** committed: additive changes (new optional fields, new check names, new `verdict.kind` values, new `detail.*` keys) ship in MINOR versions with a `### JSON API` CHANGELOG entry; shape-breaking changes (renames, removals, type changes, fixed-position reordering) require a MAJOR version + integrator notice. Exit-code contract preserved across all minor versions (D.3's `upstream_stuck` will roll up to exit code 3, not a new code). README "JSON API" section added under the `bazaar-check` documentation; `CONTRIBUTING.md` "JSON API discipline" section added with a PR self-check. TomSmart_ai's mapper-integration is the named consumer this contract is committed to. - **`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 @@ -19,10 +20,11 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### JSON API -Two additive changes to the `--log json` output this cycle, both preserving the existing envelope shape per ADR-004 Pillar 2: +X402-44 establishes the formal `### JSON API` subsection discipline going forward — every PR that touches `--log json` output must add an entry here. Two additive changes are folded in retrospectively for the v0.3.2 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. +- No other shape changes this cycle. The frozen exemplar at `tests/fixtures/bazaar/json-api-snapshot.json` captures the canonical envelope after D.4 + D.5 land. ## [0.3.1] — 2026-05-20 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4eeef79..2ca8d67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,29 @@ Not required, but helps when scanning history. --- +## JSON API discipline (`bazaar-check --log json`) + +The `bazaar-check --log json` output is a **public API contract** as of v0.3.2 (ADR-004 Pillar 2). Downstream consumers (mapper integrations, agent filters) take a runtime dependency on the shape. + +**Before merging any change that touches the JSON envelope, ask:** + +1. Does this rename, remove, or reorder a field? → **shape-breaking**; requires a major-version bump + deprecation cycle. Do NOT merge without discussion. +2. Does this add a new OPTIONAL field, new check, or new `verdict.kind` value? → **additive**; OK in a minor version. Required steps: + - Regenerate the snapshot fixture: see [`src/bazaar/json-api.md`](./src/bazaar/json-api.md#regenerating-the-snapshot-fixture) + - Add a `### JSON API` subsection to `CHANGELOG.md` `[Unreleased]` documenting what changed +3. Did the snapshot test (`tests/integration/bazaar-check-json-api.test.ts`) fail unexpectedly? → the shape changed accidentally. Either fix the code (preferred) OR if the change was intentional, follow step 2. + +**PR description self-check:** + +- [ ] Did this PR change the `--log json` output shape (any way)? +- [ ] If yes, is the change additive (new optional field) or shape-breaking (rename/removal)? +- [ ] If additive: regenerated snapshot fixture + added `### JSON API` CHANGELOG entry? +- [ ] If shape-breaking: opened a deprecation issue + notified named consumers (TomSmart_ai mapper, etc.)? + +See [`src/bazaar/json-api.md`](./src/bazaar/json-api.md) for the full contract. + +--- + ## Reporting bugs Open a GitHub issue with: diff --git a/README.md b/README.md index a01673f..4e9cf58 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,23 @@ Four read-only checks compose into a single bottom-line verdict: - Read-only. Never signs, never broadcasts. - Each HTTP probe has a 10s default timeout; use `--timeout-ms` to shorten it in CI or when checking flaky endpoints. - The opt-in paid-pass mode (`--with-wallet`) is **deferred** — see [ADR-003](./DECISIONS.md). The static-analysis-only checks shipped here cover the dominant Discord pain (Bazaar indexing failure) without needing signing infrastructure. +- **`--endpoint ` (v0.3.2+)** — skip the root `/.well-known/x402` probe and read the 402 challenge directly from a paid route. Use this for services that publish per-route only (the [#2207](https://github.com/x402-foundation/x402/issues/2207) shape — AsaiShota's test-echo-cdp, evanatpizzarobot's TensorFeed, 0xdespot's hyperD). + +### JSON API (v0.3.2+) — `--log json` is a public contract + +`bazaar-check --log json` emits a JSON envelope downstream consumers can take a runtime dependency on. Per [ADR-004 Pillar 2](./DECISIONS.md), the maintainer commits to: + +- **Additive changes** (new optional fields, new optional facets) — ship in MINOR versions +- **Shape-breaking changes** (renames, removals, type changes, fixed-position reordering) — require a MAJOR version + integrator notice +- Every change to the `--log json` output is documented in `CHANGELOG.md` under a `### JSON API` subsection + +The full contract — envelope shape, per-check `detail` keys, verdict discriminator, regeneration workflow — lives at [`src/bazaar/json-api.md`](./src/bazaar/json-api.md). A frozen exemplar lives at [`tests/fixtures/bazaar/json-api-snapshot.json`](./tests/fixtures/bazaar/json-api-snapshot.json) and is enforced by [`tests/integration/bazaar-check-json-api.test.ts`](./tests/integration/bazaar-check-json-api.test.ts). + +```bash +# Pipe the JSON into your own consumer +x402trace bazaar-check https://your-service.example.com --log json | jq '.verdict.kind' +# → "looks_correct" (or "implementation_issue" / "upstream_issue") +``` ### Chain selection (Base mainnet, v0.3+) diff --git a/src/bazaar/json-api.md b/src/bazaar/json-api.md new file mode 100644 index 0000000..58e70d3 --- /dev/null +++ b/src/bazaar/json-api.md @@ -0,0 +1,148 @@ +# `bazaar-check --log json` — Public JSON API contract + +This document is the **public API contract** for `bazaar-check --log json` output. Downstream consumers (TomSmart_ai's mapper-integration, agent-side filters, future bazaar-aware tools) take a runtime dependency on the shape described here. Per [ADR-004 Pillar 2](../../DECISIONS.md), the maintainer commits to the stability rules below. + +The frozen exemplar lives at [`tests/fixtures/bazaar/json-api-snapshot.json`](../../tests/fixtures/bazaar/json-api-snapshot.json); the test that enforces it lives at [`tests/integration/bazaar-check-json-api.test.ts`](../../tests/integration/bazaar-check-json-api.test.ts). + +## Envelope + +A single JSON object per `bazaar-check` invocation, written to stdout when `--log json` is supplied. Four top-level keys, in this order: + +```jsonc +{ + "serviceUrl": "https://...", // string — the service URL the run targets + "chain": "base-sepolia", // "base-sepolia" | "base" + "results": [...], // CheckResult[] — exactly 4 entries + "verdict": {...} // BazaarVerdict — discriminated union +} +``` + +## `results[]` — the four checks + +Always four entries, in this fixed order: + +1. `"well-known"` — root `/.well-known/x402` manifest probe +2. `"challenge"` — 402 challenge structure probe (uses `--endpoint` URL when supplied) +3. `"self-payment"` — payer ≠ payTo guard (informational when `--payer-hint` absent) +4. `"indexing"` — CDP discovery query + +Each entry is a `CheckResult`: + +```jsonc +{ + "check": "well-known", // stable identifier; downstream tools grep/filter on this + "status": "pass" | "fail" | "info", + "message": "...", // one-line human-readable description + "fix": "...", // OPTIONAL — present when status is "fail" or "info" + "detail": { ... } // OPTIONAL — per-check structured detail +} +``` + +### `status` semantics + +- **`pass`** — the check found no issue in your implementation +- **`fail`** — the check found a concrete issue you can fix +- **`info`** — the check observed an upstream-bound signal that is NOT your fault but worth surfacing (e.g. CDP indexing stuck on "processing" — the canonical [#2207](https://github.com/x402-foundation/x402/issues/2207) pattern) + +### `detail` fields per check (additive over time) + +These fields are present when the check fires the corresponding code path. Consumers should treat `detail` as additive — never assume absence means "not applicable"; always check for key presence. + +| Check | Detail keys | +| ----------- | -------------------------------------------------------------------------------------------- | +| `well-known` | `issues[]` (when status=fail), `httpStatus` (when status=fail and HTTP error) | +| `challenge` | `httpStatus` (when fetch fails), `missingFields[]` + `variant` (when extensions.bazaar fail) | +| `self-payment` | (no detail fields today) | +| `indexing` | `queryUrl`, `status` (one of `"indexed" \| "processing" \| "not_found" \| "error"`), `count` (when indexed), `httpStatus` (when HTTP error) | + +The `challenge.detail.variant` field carries the detected discovery-extension variant (`"mcp-discovery"` \| `"body-discovery"` \| `"unknown"`) on failed validations. See ADR-004 Pillar 3 for the variant model. + +## `verdict` — the discriminated union + +```jsonc +{ + "kind": "looks_correct" | "implementation_issue" | "upstream_issue", + "exitCode": 0 | 2 | 3, + "message": "...", + // Per-kind additional fields: + "failedChecks": [...] // ONLY on "implementation_issue" + "upstreamChecks": [...] // ONLY on "upstream_issue" +} +``` + +### Exit-code contract (preserved unchanged across all minor versions) + +- `0` ↔ `looks_correct` +- `2` ↔ `implementation_issue` +- `3` ↔ `upstream_issue` + +D.3's `upstream_stuck` composite (from ADR-004 Pillar 1, landing in X402-46) rolls up to exit code 3 — the verdict prose names the distinction; the exit-code surface stays a 3-value contract. Consumers grepping exit codes don't break. + +## Stability rules + +Per [ADR-004 Pillar 2](../../DECISIONS.md): + +### ✅ Additive changes — ship in MINOR versions + +- New OPTIONAL fields at any level (e.g. `verdict.severity`, `results[].detail.warningHint`) +- New CHECK names (e.g. a 5th check beyond the canonical 4) +- New `verdict.kind` discriminator values (e.g. `upstream_stuck` per D.3) +- New `detail.*` keys on existing checks (e.g. `indexer_state` for D.3) +- New OPTIONAL top-level fields + +CHANGELOG `### JSON API` entry required even for additive changes — downstream consumers track the shape, not just behavior. + +### 🚫 Shape-breaking changes — require a MAJOR version + integrator notice + +- Field RENAMES (`verdict.kind` → `verdict.type`) +- Field REMOVALS +- Type changes (`exitCode: number` → `exitCode: string`) +- REORDERING of fixed-position fields (results array order, top-level key order) +- Removing a `verdict.kind` value +- Removing a CHECK name + +Pre-major-bump steps: + +1. Open a deprecation issue, comment for at least 2 weeks +2. Notify named downstream consumers (TomSmart_ai's mapper, etc.) via DM +3. Cut a deprecation release that emits BOTH old and new shapes side-by-side under a feature flag, if the affected consumers can't update quickly +4. Major version bump + CHANGELOG `### JSON API` entry with migration notes + +## Regenerating the snapshot fixture + +When the JSON shape is intentionally changing (additive OR shape-breaking), regenerate the fixture: + +```bash +# Option A — hand-edit if you know the exact new shape +$EDITOR tests/fixtures/bazaar/json-api-snapshot.json + +# Option B — capture from a live deterministic run (recommended) +pnpm tsx -e ' + import { runBazaarCheck } from "./src/bazaar/index.js"; + // ... wire up the deterministic fetcher from the test file ... + const report = await runBazaarCheck({...}); + console.log(JSON.stringify(report, null, 2)); +' > tests/fixtures/bazaar/json-api-snapshot.json +``` + +Then add a `### JSON API` entry to `CHANGELOG.md` `[Unreleased]` documenting: + +- What field(s) changed (added / renamed / removed) +- Whether the change is additive (minor) or shape-breaking (major) +- Migration notes if shape-breaking + +## Worked example + +Running `bazaar-check https://api.example.test/api/snapshot --log json` against a service that passes all four checks produces the fixture in [`tests/fixtures/bazaar/json-api-snapshot.json`](../../tests/fixtures/bazaar/json-api-snapshot.json) verbatim. + +When `--endpoint ` is supplied, the `well-known` slot keeps the same shape but carries a different `message`: + +```jsonc +{ + "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. This is additive per Pillar 2 (no fields changed; only `message` content differs). diff --git a/tests/fixtures/bazaar/json-api-snapshot.json b/tests/fixtures/bazaar/json-api-snapshot.json new file mode 100644 index 0000000..51089bc --- /dev/null +++ b/tests/fixtures/bazaar/json-api-snapshot.json @@ -0,0 +1,36 @@ +{ + "serviceUrl": "https://api.example.test/api/snapshot", + "chain": "base-sepolia", + "results": [ + { + "check": "well-known", + "status": "pass", + "message": "manifest at https://api.example.test/.well-known/x402 is well-formed (name=\"Snapshot API\", description set, accepts [0], extensions.bazaar populated)" + }, + { + "check": "challenge", + "status": "pass", + "message": "extensions.bazaar present (name=\"Snapshot API\", description set)" + }, + { + "check": "self-payment", + "status": "pass", + "message": "no payer-hint supplied; self-payment guard skipped (pass by default)" + }, + { + "check": "indexing", + "status": "pass", + "message": "discovery returned 1 resource(s) for payTo=0x1111111111111111111111111111111111111111 — indexed", + "detail": { + "queryUrl": "https://test-discovery.example.test/v2/x402/discovery/resources?payTo=0x1111111111111111111111111111111111111111", + "status": "indexed", + "count": 1 + } + } + ], + "verdict": { + "kind": "looks_correct", + "exitCode": 0, + "message": "all checks pass. Your bazaar integration looks correct; if you're seeing a Bazaar indexing delay, give CDP 24-48h before assuming a problem." + } +} diff --git a/tests/integration/bazaar-check-json-api.test.ts b/tests/integration/bazaar-check-json-api.test.ts new file mode 100644 index 0000000..a84e7c4 --- /dev/null +++ b/tests/integration/bazaar-check-json-api.test.ts @@ -0,0 +1,194 @@ +/** + * X402-44 — JSON API stability snapshot test. + * + * Locks the `bazaar-check --log json` envelope shape against a frozen + * exemplar in `tests/fixtures/bazaar/json-api-snapshot.json`. Per ADR-004 + * Pillar 2, the JSON output is a documented public API contract; + * downstream consumers (TomSmart_ai's mapper-integration, future + * agent-side filters) take a runtime dependency on the shape. + * + * What this test catches: + * - field RENAMES — fixture says `verdict.kind`, live emits + * `verdict.type` → test fails + * - field REMOVALS — fixture has `results[3].detail.count`, live drops + * it → test fails + * - field ADDITIONS — live adds `verdict.severity`, fixture doesn't + * have it → test fails + * - field REORDERING — top-level keys emit in different order → + * stringify check fails + * + * **If this test fails and the shape change is intentional:** + * + * 1. Verify the new shape is what you want + * 2. Regenerate this fixture by running: + * `pnpm tsx tests/fixtures/bazaar/regenerate-json-api-snapshot.ts` + * (or hand-edit the JSON if the test scenario is identical) + * 3. Add a `### JSON API` subsection to CHANGELOG.md `[Unreleased]` + * documenting what changed, plus a stability note (additive vs + * shape-breaking — see src/bazaar/json-api.md for the rule) + * 4. If the change is shape-breaking (rename, removal, type change), + * bump the next release as a major version per the rule + * + * @see src/bazaar/json-api.md — the contract this test enforces + * @see DECISIONS.md ADR-004 Pillar 2 — the commitment behind the contract + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runBazaarCheck } from "../../src/bazaar/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SNAPSHOT_PATH = join(__dirname, "..", "fixtures", "bazaar", "json-api-snapshot.json"); + +const SERVICE = "https://api.example.test/api/snapshot"; +const PAY_TO = "0x1111111111111111111111111111111111111111"; +const USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const TEST_DISCOVERY = "https://test-discovery.example.test"; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +/** + * Deterministic fetcher that produces the exact responses needed for + * the snapshot scenario: well-known returns a clean mcp-discovery + * manifest, challenge returns a 402 with a matching extensions.bazaar + * block, discovery returns one resource. No timestamps, no randomness. + */ +function deterministicFetcher(): typeof fetch { + return ((urlInput: string) => { + const url = String(urlInput); + if (url.endsWith("/.well-known/x402")) { + return Promise.resolve( + jsonResponse({ + name: "Snapshot API", + description: "Frozen exemplar for ADR-004 Pillar 2", + accepts: [], + extensions: { + bazaar: { + name: "Snapshot API", + description: "Frozen exemplar for ADR-004 Pillar 2", + }, + }, + }), + ); + } + if (url.includes("discovery/resources")) { + return Promise.resolve(jsonResponse({ resources: [{ id: "r1" }] })); + } + // Challenge URL + return Promise.resolve( + new Response( + JSON.stringify({ + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: SERVICE, + payTo: PAY_TO, + asset: USDC, + maxTimeoutSeconds: 300, + }, + ], + extensions: { + bazaar: { + name: "Snapshot API", + description: "Frozen exemplar for ADR-004 Pillar 2", + }, + }, + }), + { status: 402, headers: { "content-type": "application/json" } }, + ), + ); + }) as typeof fetch; +} + +function loadSnapshot(): unknown { + return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")); +} + +describe("bazaar-check JSON API stability (X402-44, ADR-004 Pillar 2)", () => { + it("the snapshot fixture is valid JSON", () => { + expect(() => loadSnapshot()).not.toThrow(); + }); + + it("a live bazaar-check run with deterministic mocks deep-equals the snapshot fixture", async () => { + const snapshot = loadSnapshot(); + const live = await runBazaarCheck({ + serviceUrl: SERVICE, + chain: "base-sepolia", + discoveryBaseUrl: TEST_DISCOVERY, + fetcher: deterministicFetcher(), + }); + // Round-trip through JSON so the comparison reflects what consumers + // actually receive (readonly arrays vs plain arrays, undefined-vs- + // missing-key differences eliminated). + const liveJson = JSON.parse(JSON.stringify(live)); + expect(liveJson).toEqual(snapshot); + }); + + it("top-level key order is preserved (catches reordering — fragile parsers)", async () => { + const snapshot = loadSnapshot() as Record; + const live = await runBazaarCheck({ + serviceUrl: SERVICE, + chain: "base-sepolia", + discoveryBaseUrl: TEST_DISCOVERY, + fetcher: deterministicFetcher(), + }); + const liveJson = JSON.parse(JSON.stringify(live)) as Record; + expect(Object.keys(liveJson)).toEqual(Object.keys(snapshot)); + }); + + it("each result preserves key order (catches per-check shape drift)", async () => { + const snapshot = loadSnapshot() as { + results: ReadonlyArray>; + }; + const live = await runBazaarCheck({ + serviceUrl: SERVICE, + chain: "base-sepolia", + discoveryBaseUrl: TEST_DISCOVERY, + fetcher: deterministicFetcher(), + }); + const liveJson = JSON.parse(JSON.stringify(live)) as { + results: ReadonlyArray>; + }; + expect(liveJson.results).toHaveLength(snapshot.results.length); + for (let i = 0; i < snapshot.results.length; i++) { + const liveResult = liveJson.results[i]; + const snapshotResult = snapshot.results[i]; + if (liveResult === undefined || snapshotResult === undefined) { + throw new Error(`results[${i}] missing on one side; length check should have caught this`); + } + expect(Object.keys(liveResult)).toEqual(Object.keys(snapshotResult)); + } + }); + + it("the verdict preserves key order per kind (catches verdict discriminator drift)", async () => { + const snapshot = loadSnapshot() as { verdict: Record }; + const live = await runBazaarCheck({ + serviceUrl: SERVICE, + chain: "base-sepolia", + discoveryBaseUrl: TEST_DISCOVERY, + fetcher: deterministicFetcher(), + }); + const liveJson = JSON.parse(JSON.stringify(live)) as { verdict: Record }; + expect(Object.keys(liveJson.verdict)).toEqual(Object.keys(snapshot.verdict)); + expect(liveJson.verdict["kind"]).toBe(snapshot.verdict["kind"]); + expect(liveJson.verdict["exitCode"]).toBe(snapshot.verdict["exitCode"]); + }); + + it("all four canonical check names are present (catches accidental removal of a check)", () => { + const snapshot = loadSnapshot() as { + results: ReadonlyArray<{ check: string }>; + }; + const checkNames = snapshot.results.map((r) => r.check); + expect(checkNames).toEqual(["well-known", "challenge", "self-payment", "indexing"]); + }); +});