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
2 changes: 1 addition & 1 deletion .github/trigger-deploy
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1786126642
2026-08-09T16:13:57Z
2 changes: 1 addition & 1 deletion .github/trigger-test
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1786126642
2026-08-09T16:13:57Z
6 changes: 6 additions & 0 deletions scripts/rail-selfheal.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const FACILITATORS = {
robinhood: process.env.ROBINHOOD_FACILITATOR_URL || "",
stellar: process.env.STELLAR_FACILITATOR_URL || "https://channels.openzeppelin.com/x402",
algorand: process.env.ALGORAND_FACILITATOR_URL || "https://facilitator.goplausible.xyz",
// Solvador-primary (Optimism today). No OPTIMISM_FACILITATOR_URL — Solvador is
// the only settler for eip155:10. Without this entry a recovered Solvador
// never restarts us, and the rail stays dropped until a human redeploys
// (measured 2026-08-09: issue #723, Solvador /supported timed out at boot,
// then answered again while the healer kept skipping optimism).
optimism: process.env.SOLVADOR_FACILITATOR_URL || "https://api.solvador.com",
};

const j = async (url, opts = {}) => {
Expand Down
53 changes: 52 additions & 1 deletion scripts/test-openapi-fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
normaliseOpenapiTools,
openapiHasPaymentSignal,
routeQuery,
sellerDetail,
allIndexedTools,
_cacheForTests,
_resetFlatCacheForTest,
} from "../src/x402-index.js";

const fail = (m) => { console.error("FAIL:", m); process.exit(1); };
Expand Down Expand Up @@ -221,7 +224,28 @@ const openapiTools = [
ok(free.price === 0, `explicit free Bazaar price survives (got ${free.price})`);
}

// ---- 3d. Route-only fallback refuses ambiguous OpenAPI paths ----
// ---- 3d. Same-route price disagreement stays observable ----
{
const documented = [{ ...openapiTools[0], method: "GET", price: "0.003" }];
const observed = [{ ...bazaarTools[0], method: "GET", price: 0.009 }];
const [merged] = mergeOpenapiIntoBazaar(documented, observed);
ok(merged.price === 0.009, "higher observation wins the routing price when bazaar is higher");
ok(merged.priceConflict === true, "a differing current origin price is visible as a conflict");
ok(merged.priceObservations?.bazaar === 0.009, "price conflict preserves the Bazaar observation as a number");
ok(merged.priceObservations?.origin === 0.003, "price conflict preserves the origin observation as a number");
}
{
// SameDayDesk shape: stale Bazaar $0.02 vs origin $0.05 — never underquote.
const documented = [{ ...openapiTools[0], method: "GET", price: "0.05" }];
const observed = [{ ...bazaarTools[0], method: "GET", price: 0.02 }];
const [merged] = mergeOpenapiIntoBazaar(documented, observed);
ok(merged.price === 0.05, "higher origin observation wins the routing price (got " + merged.price + ")");
ok(merged.priceConflict === true, "raised origin vs stale bazaar is flagged");
ok(merged.priceObservations?.bazaar === 0.02 && merged.priceObservations?.origin === 0.05,
"both observations survive normalized");
}

// ---- 3e. Route-only fallback refuses ambiguous OpenAPI paths ----
{
const documented = [
{ ...openapiTools[0], method: "GET", slug: "read-md" },
Expand Down Expand Up @@ -300,6 +324,33 @@ seed("https://competitor.example", [
},
]);
seed("https://md.example", mergeOpenapiIntoBazaar(openapiTools, bazaarTools));
{
const conflictTools = mergeOpenapiIntoBazaar(
[{ ...openapiTools[0], method: "GET", price: "0.003", seller: "https://price-conflict.example" }],
[{ ...bazaarTools[0], method: "GET", price: 0.009, seller: "https://price-conflict.example" }],
).map((t) => ({ ...t, seller: "https://price-conflict.example" }));
seed("https://price-conflict.example", conflictTools);
const detail = sellerDetail("price-conflict.example");
const tool = detail?.tools?.find((item) => item.route === "/md");
ok(tool?.priceConflict === true, "seller detail exposes a price conflict");
ok(tool?.priceObservations?.bazaar === 0.009, "seller detail exposes the registry observation");
ok(tool?.priceObservations?.origin === 0.003, "seller detail exposes the origin observation as a number");
ok(tool?.price === 0.009, "seller detail routing price is the higher observation");

const routed = routeQuery({ query: "url to markdown", top: 5, include: "external", ...ctx });
const row = routed.results.find((x) => x.seller === "https://price-conflict.example" && x.route === "/md");
ok(!!row, "routeQuery surfaces the conflict seller");
ok(row?.priceConflict === true, "routeQuery exposes a price conflict");
ok(row?.priceObservations?.bazaar === 0.009 && row?.priceObservations?.origin === 0.003,
"routeQuery exposes both normalized observations");
ok(row?.priceUsd === 0.009, "routeQuery priceUsd matches the higher observation");

_resetFlatCacheForTest();
const listed = allIndexedTools({ excludeOrigin: "https://agent402.tools", limit: 500 });
const flatRow = listed.results.find((x) => x.seller === "https://price-conflict.example" && x.route === "/md");
ok(flatRow?.priceConflict === true, "index/tools exposes a price conflict");
ok(flatRow?.priceObservations?.origin === 0.003, "index/tools exposes origin observation");
}
{
// "url to markdown" is the seller's own operationId phrasing — the merged
// slug carries every term and must now WIN outright.
Expand Down
6 changes: 4 additions & 2 deletions scripts/test-pricing-envelope.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
// 5. categories[] is a non-empty array — homepage chips iterate over it.
// 6. endpoints[] floor: >= 400 (The 500 era: 462 interim, 500 at launch;
// alarm only on a real catalog collapse).
// 7. Per-endpoint shape: method, path, price, category, slug, description,
// 7. Per-endpoint shape: method, path, name, price, category, slug, description,
// docs, computePayable. These are the columns the listing portals
// scrape; a missing computePayable would silently make PoW eligibility
// invisible to discovery.
// invisible to discovery. `name` is what human UIs (playground dropdown,
// directory cards) render — omitting it left every label blank.
// 8. method is a known HTTP verb; path starts with /api/; price is a
// display string ("$X.XXX"); slug is non-empty; docs is a URL on BASE;
// computePayable is a boolean.
Expand Down Expand Up @@ -86,6 +87,7 @@ try {
if (typeof ep.path !== "string" || !(ep.path.startsWith("/api/") || ep.path.startsWith("/v1/"))) { fail(`endpoint path doesn't start with /api/ or /v1/ (got '${ep.path}' for slug=${ep.slug})`); break; }
if (typeof ep.price !== "string" || !ep.price.startsWith("$")) { fail(`endpoint price isn't a $-prefixed string (got '${ep.price}' for slug=${ep.slug})`); break; }
if (typeof ep.slug !== "string" || !ep.slug.length) { fail(`endpoint slug missing or empty (got '${ep.slug}')`); break; }
if (typeof ep.name !== "string" || !ep.name.length) { fail(`endpoint name missing or empty (slug=${ep.slug})`); break; }
if (typeof ep.category !== "string" || !ep.category.length) { fail(`endpoint category missing (slug=${ep.slug})`); break; }
if (typeof ep.description !== "string" || !ep.description.length) { fail(`endpoint description missing (slug=${ep.slug})`); break; }
if (typeof ep.docs !== "string" || !ep.docs.startsWith(BASE)) { fail(`endpoint docs isn't a URL on BASE (got '${ep.docs}' for slug=${ep.slug})`); break; }
Expand Down
24 changes: 23 additions & 1 deletion scripts/test-rail-selfheal.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ const healer = readFileSync(new URL("./rail-selfheal.js", import.meta.url), "utf
// Every *_FACILITATOR_URL payments.js reads is a rail that can fall on its own.
// Only NETWORKS matter here. payments.js also names facilitator VENDORS
// (payai, solvador) in the same shape; those serve many chains and fall with
// the chains they serve, so they are not something a single rail can heal from.
// the chains they serve — EXCEPT Solvador-PRIMARY rails (Optimism today), which
// have Solvador as their ONLY settler and therefore need a healer entry keyed
// by the network name, not by the vendor. See SOLVADOR_PRIMARY_CAIP2 below.
const { NETWORKS } = await import("../src/payments.js");
const networkNames = new Set(Object.keys(NETWORKS));
const dedicated = [...payments.matchAll(/([A-Z0-9]+)_FACILITATOR_URL/g)]
Expand All @@ -36,6 +38,26 @@ for (const rail of known) {
`${rail} has an entry in the healer's facilitator map - without it, that rail can never self-heal`);
}

// Solvador-primary: no OPTIMISM_FACILITATOR_URL exists, so the *_FACILITATOR_URL
// scan above misses these. Parse the CAIP-2 list and map back to NETWORKS.
{
const m = payments.match(/SOLVADOR_PRIMARY_CAIP2\s*=\s*\[([^\]]*)\]/);
ok(!!m, "payments.js declares SOLVADOR_PRIMARY_CAIP2");
const caips = [...(m?.[1].matchAll(/["']([^"']+)["']/g) || [])].map((x) => x[1]);
ok(caips.length > 0, `SOLVADOR_PRIMARY_CAIP2 is non-empty (${caips.join(", ")})`);
const caipToName = Object.fromEntries(
Object.entries(NETWORKS).map(([name, caip]) => [caip, name])
);
for (const caip of caips) {
const rail = caipToName[caip];
ok(!!rail, `SOLVADOR_PRIMARY caip ${caip} maps to a NETWORKS name`);
ok(new RegExp(`\\b${rail}\\s*:`, "i").test(healer),
`${rail} (Solvador-primary ${caip}) is in the healer map — without it a recovered Solvador never restarts us`);
}
ok(/SOLVADOR_FACILITATOR_URL|api\.solvador\.com/.test(healer),
"Solvador-primary healer entries point at Solvador (env override or default URL)");
}

// The healer must never restart on a rail it cannot confirm. That refusal is
// the whole safety property: restarting for a genuinely-down third party churns
// production without fixing anything.
Expand Down
7 changes: 7 additions & 0 deletions scripts/test-skill-page-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ try {
if (!html.includes(`href="/tools/${esc(slug)}"`)) { fail(`/skills/${pack.slug} missing tool link to /tools/${slug}`); break; }
}

// Route labels must resolve. A catalog value missing `.route` used to
// stringify as the literal "undefined" next to extract/meta/render.
if (html.includes('class="route">undefined<')) {
fail(`/skills/${pack.slug} renders literal "undefined" as a tool route`);
break;
}

if (!html.includes("claude mcp add agent402")) { fail(`/skills/${pack.slug} missing install command`); break; }
if (!html.includes(esc(pack.claudePrompt))) { fail(`/skills/${pack.slug} missing claudePrompt copy box`); break; }
if (!html.includes("application/ld+json") || !html.includes('"HowTo"')) { fail(`/skills/${pack.slug} missing JSON-LD HowTo`); break; }
Expand Down
2 changes: 1 addition & 1 deletion src/chain-logos.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading