diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eaba8f31..c804532a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -761,6 +761,9 @@ jobs: - name: Discovery & trust surfaces (/.well-known/x402, /api/reliability) run: node scripts/test-discovery.js + - name: Manifest resources[] dedup (a tool registered under 2 HTTP methods lists its URL once, not twice) + run: node scripts/test-manifest-resources-dedup.js + - name: Revenue detector classification (real x402 buys vs funding/tests) run: node scripts/test-revenue-scan.js @@ -881,6 +884,9 @@ jobs: - name: /openapi.json coverage (every CATALOG tool has a path + operationId + x-price) run: node scripts/test-openapi-coverage.js + - name: /openapi.json header parameters (Idempotency-Key on every op, X-Pow-Solution only where PoW-eligible) + run: node scripts/test-openapi-header-params.js + - name: /.well-known/x402 manifest contract (envelope + payment + capabilities + discovery) run: node scripts/test-x402-manifest.js @@ -893,6 +899,12 @@ jobs: - name: /llms.txt content lock (tool count + discovery URLs + PoW/MCP/wallet signals) run: node scripts/test-llms-txt.js + - name: /llms.txt MPP/x402 dual-header reading note (issue #794 guidance, offline) + run: node scripts/test-llms-mpp-header-note.js + + - name: /llms.txt route-execute tier pricing derived from EXEC_TIERS (a future 5th tier can't go silently missing — offline) + run: node scripts/test-llms-route-execute-tiers.js + - name: Rails-copy lock (rails.js ↔ payments.js + every key page renders every rail) run: TARGET_URL=http://localhost:3000 node scripts/test-rails.js @@ -984,6 +996,33 @@ jobs: - name: Reveal-on-scroll no hero flash (the hero must never hide behind opacity:0 waiting on the observer) run: node scripts/test-reveal-no-hero-flash.js + - name: CSP violations sitewide (real browser, every distinct page template + real interactions on click-gated pages - script-src has no 'unsafe-inline' since 2026-08-16) + run: TARGET_URL=http://localhost:3000 node scripts/test-csp-violations.js + + - name: CSS tokens resolve (every :root custom property must actually resolve in a real browser, not just exist as text) + run: TARGET_URL=http://localhost:3000 node scripts/test-css-tokens-resolve.js + + - name: Focus-visible on search/demo inputs (real browser check - inline style can silently defeat a stylesheet :focus rule) + run: TARGET_URL=http://localhost:3000 node scripts/test-focus-visible.js + + - name: Single
landmark per page (no page-level renderer may nest a second
inside ledgerShell's own) + run: TARGET_URL=http://localhost:3000 node scripts/test-single-main-landmark.js + + - name: /compare tables scroll independently on narrow viewports (real browser check - scrollWidth>clientWidth alone can't prove overflow-x is enabled) + run: TARGET_URL=http://localhost:3000 node scripts/test-compare-scroll.js + + - name: Wrong-method 405 with Allow header (a known catalog path hit with the wrong verb no longer looks like a dead route) + run: TARGET_URL=http://localhost:3000 node scripts/test-wrong-method-405.js + + - name: Reveal-on-scroll data-reveal-eager opt-in (a second above-fold section can be exempted explicitly, never leaks to unmarked pages) + run: TARGET_URL=http://localhost:3000 node scripts/test-reveal-eager.js + + - name: Reveal-on-scroll skip-over safety net (a single fast scroll jump must never leave a section permanently invisible — real browser, found live on /marketplace 2026-08-16) + run: TARGET_URL=http://localhost:3000 node scripts/test-reveal-scroll-skip.js + + - name: Hamburger menu aria-label toggles with aria-expanded (real browser click, not a static string check) + run: TARGET_URL=http://localhost:3000 node scripts/test-burger-aria-label.js + - name: Self-listing exclusion (crawled agent402.tools origin never appears as an "external" seller — offline) run: node scripts/test-self-listing-exclusion.js @@ -1005,6 +1044,9 @@ jobs: - name: Revenue buyers series (distinct-count semantics; seeded ledger — offline) run: node scripts/test-revenue-buyers.js + - name: Revenue buyers trend (rolling 14d recent-vs-prior comparison — offline) + run: node scripts/test-buyers-trend.js + - name: Settle-fallback chain (PayAI -> Solvador; double-settle gate between fallbacks — offline) run: node scripts/test-settle-fallback.js @@ -1071,9 +1113,15 @@ jobs: - name: MPP dual-stack shim (real mppx client buys over WWW-Authenticate/Authorization wire; single settle; x402 pass-through; HMAC tamper — offline) run: node scripts/test-mpp-shim.js + - name: MPP_CHALLENGE_NETWORKS default verified against installed mppx registry (drift guard — offline) + run: node scripts/test-mpp-shim-mppx-registry.js + - name: HEAD paywall bypass closed (HEAD on paid GET routes 402s with challenges + empty body; free surfaces untouched — offline) run: node scripts/test-head-paywall.js + - name: Wildcard route bypass closed (@x402/core 2.21.0 dotAll fix — percent-encoded line terminators can't skip payment on /api/convert/* — offline) + run: node scripts/test-wildcard-route-bypass.js + - name: Boot /supported guard (a dead facilitator costs ONE rail, not every paid route — the 2026-08-01 Celo outage; probe-driven drop, fail-open on total blindness, escape hatch — offline) run: node scripts/test-supported-guard.js @@ -1094,6 +1142,11 @@ jobs: REDIS_URL: redis://127.0.0.1:6379 run: node scripts/test-redis-integration.js + - name: "Replay guard, cross-replica (REAL redis — two guard instances prove shared claim/settle/release; fails open to local state on a redis outage — 2026-08-16)" + env: + REDIS_URL: redis://127.0.0.1:6379 + run: node scripts/test-replay-guard-redis.js + - name: Well-known store (operator-published domain-verification docs; traversal impossible, reserved names refused, no shadowing of /.well-known/x402 — offline) run: node scripts/test-well-known-store.js @@ -1174,6 +1227,9 @@ jobs: - name: MCP outer transport limits (R-11 — per-IP cap + concurrency ceiling bound floods before server/transport allocation — spawns child) run: node scripts/test-mcp-limits.js + - name: MCP Registry find-latest-version helper (used by the publish job to auto-deprecate a superseded version — offline) + run: node scripts/test-mcp-find-latest-version.js + - name: PostHog analytics proxy abuse controls (R-17 — method allowlist + per-IP cap before the upstream call — spawns child) run: node scripts/test-posthog-proxy.js @@ -1401,9 +1457,15 @@ jobs: - name: Operator auth (A402-07 no ?token= query; session cookie + header; hardened cookie flags — spawns child) run: node scripts/test-operator-auth.js + - name: Operator revenue visibility (zero-revenue rail flagging + top-by-revenue vs top-by-calls, offline) + run: node scripts/test-operator-revenue-visibility.js + - name: Index self-serve registration tests (offline) run: node scripts/test-index-register.js + - name: Seller registration conversion/churn table (first_seen immutable, last_settled_seen sticky — offline) + run: node scripts/test-seller-registrations.js + - name: Index openapi-fallback crawl tests (payment-signal gate, Bazaar merge, ranking — offline) run: node scripts/test-openapi-fallback.js @@ -1490,6 +1552,7 @@ jobs: node scripts/test-encoding-kit.js node scripts/test-math-kit.js node scripts/test-theme.js + node scripts/test-faint-contrast.js node scripts/test-reveal-on-scroll.js node scripts/test-validation-kit.js node scripts/test-text-analysis-kit.js @@ -2307,9 +2370,17 @@ jobs: # "mcpName" to the server.json name and confirming the npm package is # published — so this runs after the npm publish above. VER=$(node -p "require('./mcp/package.json').version") - if curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.MikeyPetrillo/agent402" | grep -q "\"version\":\"$VER\""; then + SEARCH_RESULT=$(curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.MikeyPetrillo/agent402&limit=100") + if echo "$SEARCH_RESULT" | grep -q "\"version\":\"$VER\""; then echo "io.github.mikeypetrillo/agent402@$VER already in the MCP Registry — nothing to do"; exit 0 fi + # Capture whichever version is isLatest RIGHT NOW - it's about to + # become stale the moment the new publish below lands. Deprecation + # tooling gap found 2026-08-16: nothing ever marked a superseded + # version deprecated, so 24 of 25 published versions were still + # "active" in the registry. Best-effort: a lookup failure here + # leaves PREV_LATEST empty and the deprecate step below just skips. + PREV_LATEST=$(echo "$SEARCH_RESULT" | node scripts/mcp-find-latest-version.js || true) asset="mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" if [ -n "${MCP_PUBLISHER_VERSION:-}" ]; then base="https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION"; else base="https://github.com/modelcontextprotocol/registry/releases/latest/download"; fi # Download to disk and fail fast (no curl|tar of unverified bytes). @@ -2352,6 +2423,16 @@ jobs: ../mcp-publisher login github-oidc ../mcp-publisher publish echo "published io.github.mikeypetrillo/agent402@$VER → https://registry.modelcontextprotocol.io" + # Deprecate the version this publish just superseded. Only ever + # touches the ONE immediately-previous version - never a bulk + # rewrite of history - and never fails the job: a registry hiccup + # here is a missed housekeeping step, not a publish failure. + if [ -n "$PREV_LATEST" ] && [ "$PREV_LATEST" != "$VER" ]; then + ../mcp-publisher status --status deprecated --message "Superseded by $VER" \ + io.github.MikeyPetrillo/agent402 "$PREV_LATEST" \ + && echo "deprecated superseded version $PREV_LATEST" \ + || echo "::warning::failed to deprecate superseded version $PREV_LATEST (non-fatal)" + fi probe: needs: markers diff --git a/assets/js/api-explorer.js b/assets/js/api-explorer.js new file mode 100644 index 00000000..88596d40 --- /dev/null +++ b/assets/js/api-explorer.js @@ -0,0 +1,128 @@ +(function(){ + var wrapEl=document.querySelector('.ae-wrap'); + var BASE=wrapEl?(wrapEl.getAttribute('data-base')||''):''; + var list=document.getElementById('aeList'); + var search=document.getElementById('aeSearch'); + var countEl=document.getElementById('aeCount'); + var catsEl=document.getElementById('aeCats'); + var endpoints=[]; + var activeCategory='all'; + + function el(tag,cls,text){ + var e=document.createElement(tag); + if(cls)e.className=cls; + if(text)e.textContent=text; + return e; + } + + fetch(BASE+'/openapi.json').then(function(r){return r.json()}).then(function(spec){ + var paths=spec.paths||{}; + Object.keys(paths).sort().forEach(function(p){ + var methods=paths[p]; + ['get','post','put','delete','patch'].forEach(function(m){ + if(!methods[m])return; + var op=methods[m]; + endpoints.push({method:m.toUpperCase(),path:p,name:op.summary||op.operationId||'',desc:op.description||'',category:(op.tags&&op.tags[0])||'other',schema:op.requestBody&&op.requestBody.content&&op.requestBody.content['application/json']&&op.requestBody.content['application/json'].schema||null,params:op.parameters||[]}); + }); + }); + renderCats(); + renderList(); + }).catch(function(){list.textContent='Failed to load API spec.';}); + + function renderCats(){ + while(catsEl.firstChild)catsEl.removeChild(catsEl.firstChild); + var cats={}; + endpoints.forEach(function(e){cats[e.category]=true;}); + var allBtn=el('button','ae-cat-btn active','All'); + allBtn.setAttribute('data-cat','all'); + catsEl.appendChild(allBtn); + Object.keys(cats).sort().forEach(function(c){ + var btn=el('button','ae-cat-btn',c); + btn.setAttribute('data-cat',c); + catsEl.appendChild(btn); + }); + catsEl.addEventListener('click',function(ev){ + var btn=ev.target.closest('.ae-cat-btn'); + if(!btn)return; + activeCategory=btn.getAttribute('data-cat'); + catsEl.querySelectorAll('.ae-cat-btn').forEach(function(x){x.classList.remove('active');}); + btn.classList.add('active'); + renderList(); + }); + } + + function renderList(){ + var q=search.value.toLowerCase().trim(); + while(list.firstChild)list.removeChild(list.firstChild); + var shown=0; + endpoints.forEach(function(ep){ + if(activeCategory!=='all'&&ep.category!==activeCategory)return; + if(q&&ep.method.toLowerCase().indexOf(q)===-1&&ep.path.toLowerCase().indexOf(q)===-1&&ep.name.toLowerCase().indexOf(q)===-1&&ep.desc.toLowerCase().indexOf(q)===-1)return; + shown++; + + var div=el('div','ae-endpoint'); + var head=el('div','ae-ep-head'); + head.appendChild(el('span','ae-method '+ep.method,ep.method)); + head.appendChild(el('span','ae-path',ep.path)); + head.appendChild(el('span','ae-ep-name',ep.name)); + head.addEventListener('click',function(){div.classList.toggle('open');}); + div.appendChild(head); + + var body=el('div','ae-ep-body'); + + if(ep.desc){ + var sec=el('div','ae-section'); + sec.appendChild(el('div','ae-section-title','Description')); + var p=el('p','',ep.desc); + p.style.cssText='font-size:.9rem;color:var(--muted);margin:0'; + sec.appendChild(p); + body.appendChild(sec); + } + + if(ep.params.length){ + var sec2=el('div','ae-section'); + sec2.appendChild(el('div','ae-section-title','Parameters')); + var schema=el('div','ae-schema'); + ep.params.forEach(function(pm){ + var row=el('div','ae-prop'); + row.appendChild(el('span','ae-prop-name',pm.name)); + row.appendChild(document.createTextNode(' ')); + row.appendChild(el('span','ae-prop-type',pm.in||'query')); + if(pm.description){row.appendChild(document.createTextNode(' '));row.appendChild(el('span','ae-prop-desc',pm.description));} + schema.appendChild(row); + }); + sec2.appendChild(schema); + body.appendChild(sec2); + } + + if(ep.schema&&ep.schema.properties){ + var sec3=el('div','ae-section'); + sec3.appendChild(el('div','ae-section-title','Request Body')); + var schema2=el('div','ae-schema'); + Object.keys(ep.schema.properties).forEach(function(k){ + var prop=ep.schema.properties[k]; + var row=el('div','ae-prop'); + row.appendChild(el('span','ae-prop-name',k)); + row.appendChild(document.createTextNode(' ')); + row.appendChild(el('span','ae-prop-type',prop.type||'any')); + if(prop.description){row.appendChild(document.createTextNode(' '));row.appendChild(el('span','ae-prop-desc',prop.description));} + schema2.appendChild(row); + }); + sec3.appendChild(schema2); + body.appendChild(sec3); + } + + var sec4=el('div','ae-section'); + var tryBtn=el('a','ae-try-btn','Try in Playground →'); + tryBtn.href='/playground'; + sec4.appendChild(tryBtn); + body.appendChild(sec4); + + div.appendChild(body); + list.appendChild(div); + }); + countEl.textContent=shown+' endpoint'+(shown===1?'':'s'); + } + + search.addEventListener('input',renderList); +})(); diff --git a/assets/js/catalog-search.js b/assets/js/catalog-search.js new file mode 100644 index 00000000..21a68501 --- /dev/null +++ b/assets/js/catalog-search.js @@ -0,0 +1,25 @@ +(function() { + var search = document.getElementById('cat-search'); + var rows = document.querySelectorAll('.cat-row'); + var empty = document.getElementById('cat-empty'); + var emptyQ = document.getElementById('cat-empty-q'); + function applyFilter() { + var q = (search.value || '').toLowerCase().trim(); + var visible = 0; + rows.forEach(function(row) { + var label = row.querySelector('th a').textContent.toLowerCase(); + var blurb = row.querySelector('.cat-blurb').textContent.toLowerCase(); + var match = !q || label.indexOf(q) !== -1 || blurb.indexOf(q) !== -1; + row.style.display = match ? '' : 'none'; + if (match) visible++; + }); + empty.style.display = visible === 0 ? 'block' : 'none'; + if (visible === 0) emptyQ.textContent = search.value; + } + search.addEventListener('input', applyFilter); + try { + var params = new URLSearchParams(window.location.search); + var q0 = params.get('q'); + if (q0) { search.value = q0; applyFilter(); } + } catch (e) {} +})(); diff --git a/assets/js/copy-buttons.js b/assets/js/copy-buttons.js new file mode 100644 index 00000000..32529c07 --- /dev/null +++ b/assets/js/copy-buttons.js @@ -0,0 +1,25 @@ +(function(){ + // Shared "copy code snippet" button handler for every page that has one: + // adapter docs (.ml-adp-copy), badges (.bdg-copy), contribute (.ct-copy), + // quickstart (.qs-copy). All four copy their own sibling's text and + // give the same "Copied!" feedback - unified into one file (CSP hardening, + // 2026-08-16) instead of four near-identical inline scripts. .ml-adp-copy + // originally used inline style.color instead of a "copied" CSS class; + // both behaviors are preserved exactly, keyed by which class matched, so + // no page's visual behavior changed. + function wire(selector, feedback){ + document.querySelectorAll(selector).forEach(function(btn){ + btn.addEventListener("click",function(){ + var code=btn.parentElement.querySelector("code"); + var text=code.textContent; + navigator.clipboard.writeText(text).then(function(){ + btn.textContent="Copied!"; + feedback(btn, true); + setTimeout(function(){btn.textContent="Copy";feedback(btn, false);},1500); + }); + }); + }); + } + wire(".ml-adp-copy", function(btn, on){ btn.style.color = on ? "var(--accent)" : ""; }); + wire(".bdg-copy, .ct-copy, .qs-copy", function(btn, on){ btn.classList.toggle("copied", on); }); +})(); diff --git a/assets/js/docs-sidebar.js b/assets/js/docs-sidebar.js new file mode 100644 index 00000000..737c36b5 --- /dev/null +++ b/assets/js/docs-sidebar.js @@ -0,0 +1,30 @@ +(function(){ + var toggle=document.getElementById('ml-docs-mobile-toggle'); + var side=document.getElementById('ml-docs-side'); + if(!toggle||!side)return; + toggle.addEventListener('click',function(){ + var open=side.classList.toggle('ml-docs-side-open'); + toggle.setAttribute('aria-expanded',open?'true':'false'); + }); +})(); +(function(){ + var input=document.getElementById('ml-docs-search-input'); + if(!input)return; + var side=input.closest('.ml-docs-side'); + if(!side)return; + input.addEventListener('input',function(){ + var q=input.value.trim().toLowerCase(); + var lists=side.querySelectorAll('.ml-docs-side-ul'); + lists.forEach(function(ul){ + var anyVisible=false; + ul.querySelectorAll('li').forEach(function(li){ + var match=!q||(li.textContent||'').toLowerCase().indexOf(q)>-1; + li.classList.toggle('ml-docs-side-hidden',!match); + if(match)anyVisible=true; + }); + var h=ul.previousElementSibling; + if(h&&h.classList.contains('ml-docs-side-h'))h.classList.toggle('ml-docs-side-hidden',!anyVisible); + ul.classList.toggle('ml-docs-side-hidden',!anyVisible); + }); + }); +})(); diff --git a/assets/js/home-hero.js b/assets/js/home-hero.js new file mode 100644 index 00000000..1201e2b4 --- /dev/null +++ b/assets/js/home-hero.js @@ -0,0 +1,249 @@ +(function() { + // --- live counter + rails/leaderboard poll (seeds from server-rendered + // values above so the number is real and SEO-visible even with JS off; + // polling every 30s is a pure enhancement on top). --- + var counterEl = document.getElementById('hm-counter'); + var counterEmptyEl = document.getElementById('hm-counter-empty'); + var freePowEl = document.getElementById('hm-freepow'); + var shownN = Number(counterEl ? counterEl.getAttribute('data-via-usdc') : 0) || 0; + function animateTo(target, dur) { + var from = shownN, t0 = performance.now(); + function step(t) { + var p = Math.min(1, (t - t0) / dur); + var eased = 1 - Math.pow(1 - p, 3); + var n = Math.round(from + (target - from) * eased); + counterEl.textContent = n.toLocaleString('en-US'); + if (n > 0) { counterEl.style.display = ''; counterEmptyEl.style.display = 'none'; } + if (p < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + } + function pollStats() { + fetch('/api/stats', { headers: { accept: 'application/json' } }).then(function(r) { return r.ok ? r.json() : null; }).then(function(j) { + if (!j || !j.toolCallsServed) return; + var paid = Number(j.toolCallsServed.viaUSDC) || 0; + var pow = Number(j.toolCallsServed.viaProofOfWork) || 0; + if (freePowEl) freePowEl.textContent = pow.toLocaleString('en-US'); + if (paid && paid !== shownN) { animateTo(paid, shownN ? 1000 : 1500); shownN = paid; } + }).catch(function() {}); + } + setInterval(pollStats, 30000); + + // --- real, live proof-of-work demo: fetch a real challenge, solve it in + // this tab, submit it, exactly matching src/pow.js's own semantics + // (hash the "challenge" field, submit the "token" field). --- + var input = document.getElementById('hm-demo-in'); + var runBtn = document.getElementById('hm-demo-run'); + var statusEl = document.getElementById('hm-demo-status'); + var outEl = document.getElementById('hm-demo-out'); + var receiptEl = document.getElementById('hm-demo-receipt'); + var step1 = document.getElementById('hm-step1'), step1m = document.getElementById('hm-step1-mark'); + var step2 = document.getElementById('hm-step2'), step2m = document.getElementById('hm-step2-mark'); + var step3 = document.getElementById('hm-step3'), step3m = document.getElementById('hm-step3-mark'); + var busy = false; + + function lzOf(buf) { + var bits = 0, arr = new Uint8Array(buf); + for (var i = 0; i < arr.length; i++) { + if (arr[i] === 0) { bits += 8; continue; } + bits += Math.clz32(arr[i]) - 24; + break; + } + return bits; + } + + runBtn.addEventListener('click', function() { + if (busy) return; + var text = (input.value || 'hello').slice(0, 200); + busy = true; + runBtn.textContent = 'WORKING…'; + statusEl.textContent = 'challenge'; + outEl.textContent = 'requesting a signed challenge…'; + receiptEl.textContent = 'waiting on the live server'; + step1m.textContent = '·'; step2m.textContent = '·'; step3m.textContent = '·'; + + fetch('/api/pow/challenge?slug=hash', { headers: { accept: 'application/json' } }) + .then(function(r) { if (!r.ok) throw new Error('challenge returned ' + r.status); return r.json(); }) + .then(function(c) { + if (!c || !c.challenge || !c.token) throw new Error('challenge response missing challenge/token'); + statusEl.textContent = c.difficulty + '-bit puzzle'; + step1m.textContent = '✓'; + step1.textContent = c.difficulty + '-bit sha256 puzzle issued'; + outEl.textContent = 'solving…'; + + var t0 = performance.now(); + var enc = new TextEncoder(); + var BATCH = 512, CAP = 4000000, base = 0, nonce = null; + + function solveBatch() { + var jobs = []; + for (var i = 0; i < BATCH; i++) jobs.push(crypto.subtle.digest('SHA-256', enc.encode(c.challenge + ':' + (base + i)))); + return Promise.all(jobs).then(function(digests) { + for (var i = 0; i < BATCH; i++) { + if (lzOf(digests[i]) >= c.difficulty) { nonce = base + i; return; } + } + base += BATCH; + outEl.textContent = 'solving… ' + base.toLocaleString('en-US') + ' hashes tried'; + step2.textContent = base.toLocaleString('en-US') + ' hashes…'; + if (base > CAP) throw new Error('gave up after 4M hashes'); + return solveBatch(); + }); + } + + return solveBatch().then(function() { + var ms = Math.round(performance.now() - t0); + step2m.textContent = '✓'; + step2.textContent = 'nonce ' + nonce.toLocaleString('en-US') + ' found in ' + ms + 'ms'; + statusEl.textContent = 'X-Pow-Solution sent'; + outEl.textContent = 'solution accepted locally - calling the tool…'; + receiptEl.textContent = 'solved in ' + ms + 'ms'; + return fetch('/api/hash', { + method: 'POST', + headers: { 'content-type': 'application/json', 'X-Pow-Solution': c.token + ':' + nonce }, + body: JSON.stringify({ text: text, algo: 'sha256' }), + }).then(function(rr) { + return rr.text().then(function(body) { + if (!rr.ok) throw new Error('tool returned ' + rr.status + ' - ' + body.slice(0, 160)); + var out = body; + try { out = JSON.stringify(JSON.parse(body), null, 2); } catch (e) {} + step3m.textContent = '✓'; + step3.textContent = 'served free - no payment, no key'; + statusEl.textContent = '200 OK'; + outEl.textContent = out; + receiptEl.textContent = 'paid with ' + (nonce + 1).toLocaleString('en-US') + ' hashes · ' + ms + 'ms of your CPU · $0.00 · nonce ' + nonce.toLocaleString('en-US'); + }); + }); + }); + }) + .catch(function(e) { + statusEl.textContent = 'error'; + outEl.textContent = "couldn't complete the live call: " + (e && e.message ? e.message : 'unknown error'); + receiptEl.textContent = 'try again, or see the curl example above'; + }) + .then(function() { + busy = false; + runBtn.textContent = 'RUN IT AGAIN'; + }); + }); + + // --- inline seller registration (same POST /api/index/register the /sell + // page's own form uses - listing an API shouldn't require a click-through + // to a second page just to paste one URL). --- + var regBtn = document.getElementById('hm-reg-go'); + var regIn = document.getElementById('hm-reg-origin'); + var regOut = document.getElementById('hm-reg-out'); + // The two hero-style "LIST YOUR API" buttons jump here via #sell instead of + // navigating to /sell - focus the input so the jump visibly lands on + // something typeable, not just a scroll position. + document.querySelectorAll('a[href="#sell"]').forEach(function(a) { + a.addEventListener('click', function() { setTimeout(function() { if (regIn) regIn.focus(); }, 400); }); + }); + if (regBtn) { + function submitReg() { + regOut.textContent = 'probing...'; + fetch('/api/index/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ origin: regIn.value }) }) + .then(function(r) { return r.json(); }) + .then(function(j) { + regOut.textContent = j.listed + ? ('Listed - ' + (j.seller && j.seller.displayName ? j.seller.displayName : j.origin) + ' (' + (j.seller && j.seller.toolCount ? j.seller.toolCount : 0) + ' tools). Appears on /marketplace and any chain page it advertises.') + : ('Not listed: ' + (j.error || 'unknown error')); + }) + .catch(function() { regOut.textContent = 'submission failed - try again'; }); + } + regBtn.addEventListener('click', submitReg); + regIn.addEventListener('keydown', function(e) { if (e.key === 'Enter') submitReg(); }); + } + + // --- reveal-on-scroll: the shared script in ledger-chrome.js now applies + // this to every header/section site-wide on its own; no per-page opt-in + // needed here anymore. --- + + // --- dot world map: real Natural Earth geometry (world-atlas 110m, public + // domain), rasterised to a land mask, sampled into a dot grid, with + // animated settlement arcs. Waits for the pinned d3/topojson tags above. --- + (function() { + var c = document.getElementById('hm-map'); + if (!c) return; + function waitForLibs(timeoutMs) { + return new Promise(function(resolve, reject) { + var t0 = Date.now(); + (function tick() { + if (window.d3 && window.topojson) return resolve(); + if (Date.now() - t0 > (timeoutMs || 8000)) return reject(new Error('d3/topojson did not load')); + setTimeout(tick, 60); + })(); + }); + } + waitForLibs().then(function() { + return fetch('https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json').then(function(r) { return r.json(); }); + }).then(function(topo) { + var all = window.topojson.feature(topo, topo.objects.countries); + var land = { type: 'FeatureCollection', features: all.features.filter(function(f) { return String(f.id) !== '010'; }) }; + var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var dpr = Math.min(2, window.devicePixelRatio || 1); + var ctx = c.getContext('2d'); + var W = 0, H = 0, dots = [], arcs = [], raf = null; + + function build() { + W = Math.max(300, c.parentElement.clientWidth); + H = Math.round(W / 2.05); + c.width = W * dpr; c.height = H * dpr; + c.style.height = H + 'px'; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + var proj = window.d3.geoEquirectangular().fitExtent([[8, 8], [W - 8, H - 8]], land); + var off = document.createElement('canvas'); + off.width = W; off.height = H; + var octx = off.getContext('2d'); + octx.fillStyle = '#fff'; + octx.beginPath(); + window.d3.geoPath(proj, octx)(land); + octx.fill(); + var px = octx.getImageData(0, 0, W, H).data; + dots = []; + var step = W > 460 ? 4 : 5; + for (var y = 0; y < H; y += step) for (var x = 0; x < W; x += step) if (px[(y * W + x) * 4 + 3] > 140) dots.push([x + 0.5, y + 0.5]); + arcs = []; + var minSpan = W * 0.24; + for (var i = 0; i < 9 && dots.length > 40; i++) { + var a = null, b = null; + for (var tries = 0; tries < 60; tries++) { + var p = dots[(Math.random() * dots.length) | 0]; + var q = dots[(Math.random() * dots.length) | 0]; + if (Math.hypot(p[0] - q[0], p[1] - q[1]) >= minSpan) { a = p; b = q; break; } + } + if (a && b) arcs.push({ a: a, b: b, phase: Math.random() }); + } + } + + function draw(t) { + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#8C8C88'; + ctx.globalAlpha = 0.44; + for (var i = 0; i < dots.length; i++) { ctx.beginPath(); ctx.arc(dots[i][0], dots[i][1], 1.05, 0, 6.2832); ctx.fill(); } + for (var j = 0; j < arcs.length; j++) { + var arc = arcs[j], a = arc.a, b = arc.b; + var span = Math.hypot(a[0] - b[0], a[1] - b[1]); + var mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2 - span * 0.34; + ctx.globalAlpha = 0.2; ctx.strokeStyle = '#F0522E'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.quadraticCurveTo(mx, my, b[0], b[1]); ctx.stroke(); + ctx.fillStyle = '#F0522E'; ctx.globalAlpha = 0.7; + ctx.beginPath(); ctx.arc(a[0], a[1], 2, 0, 6.2832); ctx.fill(); + ctx.beginPath(); ctx.arc(b[0], b[1], 2, 0, 6.2832); ctx.fill(); + if (!reduce) { + var tt = (t * 0.00014 + arc.phase) % 1, u = 1 - tt; + var qx = u * u * a[0] + 2 * u * tt * mx + tt * tt * b[0]; + var qy = u * u * a[1] + 2 * u * tt * my + tt * tt * b[1]; + ctx.globalAlpha = 0.22; ctx.beginPath(); ctx.arc(qx, qy, 6.5, 0, 6.2832); ctx.fill(); + ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(qx, qy, 2.6, 0, 6.2832); ctx.fill(); + } + } + ctx.globalAlpha = 1; + if (!reduce) raf = requestAnimationFrame(draw); + } + + build(); + window.addEventListener('resize', function() { build(); if (reduce) draw(0); }); + if (reduce) draw(0); else raf = requestAnimationFrame(draw); + }).catch(function() { /* map is decorative - silently fall back to the panel without it */ }); + })(); +})(); diff --git a/assets/js/market-filter-bar.js b/assets/js/market-filter-bar.js new file mode 100644 index 00000000..67f8a9c3 --- /dev/null +++ b/assets/js/market-filter-bar.js @@ -0,0 +1,20 @@ +document.addEventListener('DOMContentLoaded',function(){ + var rows=Array.prototype.slice.call(document.querySelectorAll('[data-mfb-row]')); + if(!rows.length)return; + var parent=rows[0].parentNode; + var num=function(el,k){var v=Number(el.getAttribute('data-'+k));return isFinite(v)?v:0;}; + var sortSel=document.querySelector('select[data-mfb-sort]'); + if(sortSel)sortSel.addEventListener('change',function(){ + var k=sortSel.value; + rows.slice().sort(function(a,b){ + if(k==='health'&&num(a,'health')!==num(b,'health'))return num(b,'health')-num(a,'health'); + var m=k==='health'?'calls':k; + return num(b,m)-num(a,m); + }).forEach(function(r){parent.appendChild(r);}); + }); + var searchIn=document.querySelector('input[data-mfb-search]'); + if(searchIn)searchIn.addEventListener('input',function(){ + var q=searchIn.value.trim().toLowerCase(); + rows.forEach(function(r){r.style.display=!q||(r.textContent||'').toLowerCase().indexOf(q)>-1?'':'none';}); + }); +}); diff --git a/assets/js/market-seller-switch.js b/assets/js/market-seller-switch.js new file mode 100644 index 00000000..e10b2f5a --- /dev/null +++ b/assets/js/market-seller-switch.js @@ -0,0 +1,28 @@ +(function(){ + // In-place seller switching: fetch the same-origin, server-rendered (and fully + // escaped) market panel and swap it without a full reload. Progressive + // enhancement - the roster links are real hrefs, so this whole block is a + // no-op fallback to normal navigation when JS/fetch/history are unavailable + // or a request fails. Content is parsed with createContextualFragment + + // replaceChildren (not innerHTML); it is our own output, never user input. + var panel=document.getElementById('market-panel'); + var CHAIN=panel?(panel.getAttribute('data-chain')||''):''; + if(!panel||!window.fetch||!window.history||!history.pushState||!document.createRange().createContextualFragment)return; + function loading(on){panel.style.transition='opacity .15s';panel.style.opacity=on?'.5':'';} + function mark(host){document.querySelectorAll('[data-seller-link]').forEach(function(a){var h=a.getAttribute('data-seller-host')||'';a.classList.toggle('sel',host?(h===host):(a.getAttribute('data-seller-local')==='1'));});} + function swap(html){panel.replaceChildren(document.createRange().createContextualFragment(html));} + function load(host,push){ + loading(true); + return fetch('/api/market/'+CHAIN+'/panel'+(host?('?seller='+encodeURIComponent(host)):''),{headers:{accept:'application/json'}}) + .then(function(r){if(!r.ok)throw 0;return r.json();}) + .then(function(j){swap(j.html);mark(host);loading(false); + if(push){var u=host?(location.pathname.split('?')[0]+'?seller='+encodeURIComponent(host)):location.pathname.split('?')[0];history.pushState({s:host},'',u+'#activity');var el=document.getElementById('activity');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});} + return true;}); + } + document.addEventListener('click',function(e){ + var a=e.target.closest&&e.target.closest('[data-seller-link]');if(!a)return; + e.preventDefault();var host=a.getAttribute('data-seller-host')||''; + load(host,true).catch(function(){window.location.href=a.getAttribute('href');}); + }); + window.addEventListener('popstate',function(){var m=location.search.match(/[?&]seller=([^&#]+)/);load(m?decodeURIComponent(m[1]):'',false).catch(function(){location.reload();});}); +})(); diff --git a/assets/js/operator-dashboard.js b/assets/js/operator-dashboard.js new file mode 100644 index 00000000..e9575fad --- /dev/null +++ b/assets/js/operator-dashboard.js @@ -0,0 +1,68 @@ +(function(){ + // The session cookie authenticates same-origin requests automatically; nav is + // plain links and the refresh below needs no token handling. + function esc(t){ return String(t==null?'':t).replace(/[&<>"']/g,function(c){return({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c];}); } + var tbody=document.getElementById('tbody'); + var feed=document.getElementById('feed'); + // Live per-tool data - was templated directly into this script's text + // (`rowsCache=${JSON.stringify(tools)}`); now read from the JSON island + // op-rows-data emits (CSP hardening, 2026-08-16 - this value is genuinely + // per-request, unlike most of this site's other inline-script data). + var rowsDataEl = document.getElementById('op-rows-data'); + var sortK='calls', sortDir=-1, rowsCache = rowsDataEl ? JSON.parse(rowsDataEl.textContent) : []; + + function renderRows(){ + var q=(document.getElementById('filter').value||'').toLowerCase(); + var rs=rowsCache.filter(function(r){ return !q || r.slug.toLowerCase().indexOf(q)>=0; }); + rs.sort(function(a,b){ + var k=sortK==='price'?'pricePerCall':(sortK==='rev'?'revenueUsd':sortK); + var av=a[k], bv=b[k]; + if(typeof av==='string') return sortDir*av.localeCompare(bv); + return sortDir*((av||0)-(bv||0)); + }); + var html = rs.length ? rs.map(function(r){ + var b = r.walletOnly + ? 'USDC-ONLY' + : 'FREE-W/POW'; + return ''+esc(r.slug)+' '+b+''+ + ''+esc(r.calls)+''+ + ''+esc(r.paid)+''+ + ''+esc(r.pow)+''+ + ''+esc(r.heartbeat||0)+''+ + '$'+esc(r.revenueUsd.toFixed(4))+''+ + '$'+esc(r.pricePerCall.toFixed(4))+''; + }).join('') : 'No matches.'; + tbody.innerHTML = html; /* eslint-disable-line -- pre-existing AJAX table refresh; all values esc()-d */ + } + document.getElementById('filter').addEventListener('input', renderRows); + document.querySelectorAll('th[data-k]').forEach(function(th){ + th.addEventListener('click', function(){ + var k=th.getAttribute('data-k'); + if(sortK===k) sortDir=-sortDir; else { sortK=k; sortDir=-1; } + renderRows(); + }); + }); + + async function tick(){ + try { + var r=await fetch('/__operator/stats',{cache:'no-store'}); + if(!r.ok) return; + var d=await r.json(); + var tt=d.totals||{}; + document.getElementById('t-total').textContent=tt.total||0; + document.getElementById('t-usdc').textContent=tt.viaUSDC||0; + document.getElementById('t-pow').textContent=tt.viaProofOfWork||0; + document.getElementById('t-hb').textContent=tt.viaHeartbeat||0; + document.getElementById('t-rev').textContent='$'+((tt.estimatedRevenueUsd||0).toFixed(4)); + document.getElementById('t-tools').textContent=tt.toolsServed||0; + document.getElementById('t-up').textContent=Math.floor((d.processUptimeSeconds||0)/3600)+'h'; + rowsCache=d.tools||[]; renderRows(); + var feedHtml=(d.recentCalls||[]).map(function(x){ + var m=x.paidWith==='proof-of-work'?'PoW':x.paidWith==='heartbeat'?'HB':'$ USDC'; + return '
  • '+esc(x.slug)+''+m+''+esc(x.at)+'
  • '; + }).join('') || '
  • No recent activity.
  • '; + feed.innerHTML = feedHtml; /* eslint-disable-line -- pre-existing AJAX feed refresh; all values esc()-d */ + } catch(e) { /* ignore */ } + } + setInterval(tick, 10000); +})(); diff --git a/assets/js/operator-login.js b/assets/js/operator-login.js new file mode 100644 index 00000000..267ffd26 --- /dev/null +++ b/assets/js/operator-login.js @@ -0,0 +1,9 @@ +(function(){ + var f=document.getElementById('f'), t=document.getElementById('t'), e=document.getElementById('e'); + f.addEventListener('submit', function(ev){ + ev.preventDefault(); e.textContent=''; + fetch('/__operator/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:t.value})}) + .then(function(r){ if(r.ok){ location.href='/__operator'; } else { e.textContent='Invalid token.'; } }) + .catch(function(){ e.textContent='Sign-in failed.'; }); + }); +})(); diff --git a/assets/js/pages-tool-search.js b/assets/js/pages-tool-search.js new file mode 100644 index 00000000..0d42e647 --- /dev/null +++ b/assets/js/pages-tool-search.js @@ -0,0 +1,38 @@ +(function(){ + var input=document.getElementById('tool-search'), count=document.getElementById('tool-search-count'); + if(!input) return; + // Moved off inline onfocus/onblur attributes (CSP hardening, 2026-08-16) - + // same visual behavior, just wired via addEventListener instead. + input.addEventListener('focus', function(){ input.style.borderColor = '#4ade80'; }); + input.addEventListener('blur', function(){ input.style.borderColor = '#1e2638'; }); + input.addEventListener('input', function(){ + var q = this.value.toLowerCase().trim(); + var cards = document.querySelectorAll('.card'); + var sections = document.querySelectorAll('h2'); + var shown = 0; + cards.forEach(function(c){ + var text = (c.textContent || '').toLowerCase(); + var match = !q || text.indexOf(q) !== -1; + c.style.display = match ? '' : 'none'; + if (match) shown++; + }); + sections.forEach(function(s){ + if (!q) { s.style.display = ''; return; } + var next = s.nextElementSibling; + while (next && !next.matches('h2')) { + if (next.classList && next.classList.contains('grid')) { + var vis = next.querySelectorAll('.card:not([style*="display: none"])'); + s.style.display = vis.length ? '' : 'none'; + break; + } + if (next.classList && next.classList.contains('cat-blurb')) { + next.style.display = s.style.display; + next = next.nextElementSibling; + continue; + } + next = next.nextElementSibling; + } + }); + count.textContent = q ? shown + ' match' + (shown === 1 ? '' : 'es') : ''; + }); +})(); diff --git a/assets/js/playground.js b/assets/js/playground.js new file mode 100644 index 00000000..b5251e1e --- /dev/null +++ b/assets/js/playground.js @@ -0,0 +1,347 @@ +(function(){ + var wrapEl=document.querySelector('.pg-wrap'); + var BASE=wrapEl?(wrapEl.getAttribute('data-base')||''):''; + var tools=[]; + var toolMap={}; + var selEl=document.getElementById('pgSelect'); + var searchEl=document.getElementById('pgSearch'); + var formEl=document.getElementById('pgForm'); + var resultEl=document.getElementById('pgResult'); + + function escH(s){ + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + /* --- load catalog (embedded at render time via the pg-tools-data JSON + island; see playgroundPage) --- */ + (function loadCatalog(){ + var dataEl=document.getElementById('pg-tools-data'); + var data=dataEl?JSON.parse(dataEl.textContent):[]; + tools=(data||[]).slice().sort(function(a,b){ + if(a.categoryb.category)return 1; + var an=(a.name||a.slug||''); + var bn=(b.name||b.slug||''); + return an=difficulty) return nonce; + nonce++; + if(nonce%5000===0) await new Promise(function(r){setTimeout(r,0)}); + } + } + + /* --- JSON syntax highlight (operates on pre-serialized, HTML-escaped JSON) --- */ + function highlightJson(str){ + var safe=escH(str); + return safe + .replace(/("(?:[^&]|&(?!quot;))*?")\s*:/g, + '$1:') + .replace(/:\s*("(?:[^&]|&(?!quot;))*?")/g, + ': $1') + .replace(/:\s*(-?\d+\.?\d*(?:[eE][+-]?\d+)?)/g, + ': $1') + .replace(/:\s*(true|false)/g, + ': $1') + .replace(/:\s*(null)/g, + ': $1'); + } + + /* --- run tool --- */ + async function runTool(t){ + var btn=document.getElementById('pgRun'); + var status=document.getElementById('pgStatus'); + btn.disabled=true; + + /* status: solving */ + while(status.firstChild)status.removeChild(status.firstChild); + var spinSpan=document.createElement('span'); + spinSpan.className='spin'; + status.appendChild(spinSpan); + status.appendChild(document.createTextNode(' Solving proof-of-work...')); + + /* result: running */ + while(resultEl.firstChild)resultEl.removeChild(resultEl.firstChild); + var runPlaceholder=document.createElement('div'); + runPlaceholder.className='placeholder'; + runPlaceholder.textContent='Running...'; + resultEl.appendChild(runPlaceholder); + + try{ + /* gather params */ + var params={}; + var inputs=formEl.querySelectorAll('[data-key]'); + for(var i=0;i0){d.oth=d.oth||{};d.oth[r.chain]=(d.oth[r.chain]||0)+v8}}})} + // Free calls are counts, never dollars - a free call earns $0 by + // definition, so the lane is absent under the Revenue $ metric rather + // than drawn as a bar pretending call count is revenue. The metric/ + // traffic handlers already keep those two from being selected together; + // this is defence in depth for any future caller that sets state directly. + if(state.traffic!=="paid"&&state.metric==="tx"){ + state.free.forEach(function(r){var d=dayOf(r.day);d.slots.free=(d.slots.free||0)+(r.pow||0)})} + var list=Object.keys(days).sort().map(function(k){return days[k]}); + if(state.mode==="cum"){var acc={},accO={};list.forEach(function(d){var O=ORDERS();for(var k=0;k=1?"$"+v.toFixed(2):"$"+v.toFixed(4)):String(Math.round(v))} + function render(){ + var data=build(), svg=document.getElementById("rvzSvg"), tip=document.getElementById("rvzTip"); + if(!data.length){svg.outerHTML="";document.querySelector(".rvz-wrap").innerHTML='
    ledger backfilling - the series appears as settlements sync
    ';return} + var W=940,H=300,L=52,R=8,T=10,B=26,pw=W-L-R,ph=H-T-B; + var ORD=ORDERS(); + var max=0;data.forEach(function(d){var t=0;for(var k=0;kmax)max=t}); + max=max||1; + var n=data.length, bw=Math.max(2,Math.min(34,pw/n-2)); + var x=function(i){return L+(pw/n)*i+(pw/n-bw)/2}, y=function(v){return T+ph-(v/max)*ph}; + var out=[]; + for(var g=0;g<=3;g++){var gv=max*g/3,gy=y(gv); + out.push(''); + out.push(''+fmt(gv)+"")} + var step=Math.ceil(n/8); + data.forEach(function(d,i){ + if(i%step===0)out.push(''+d.day.slice(5)+""); + var y0=T+ph; + for(var k=0;k')} + else{out.push('')}} + out.push('')}); + svg.innerHTML=out.join(""); + svg.onmousemove=function(ev){var t=ev.target.closest("rect[data-i]");if(!t){tip.style.display="none";return} + var d=data[+t.dataset.i],rows="",tot=0; + for(var k=ORD.length-1;k>=0;k--){var s=ORD[k];var v=d.slots[s]||0;if(v<=0)continue;tot+=v; + rows+='
    '+NAMES[s]+" "+fmt(v)+"
    "; + if(s===8&&d.oth){Object.keys(d.oth).sort().forEach(function(c){if(d.oth[c]>0) + rows+='
    '+chainName(c)+" "+fmt(d.oth[c])+"
    "})}} + tip.innerHTML=""+d.day+""+rows+"
    total "+fmt(tot)+"
    "; + var wr=document.querySelector(".rvz-wrap").getBoundingClientRect(); + tip.style.display="block";tip.style.left=Math.min(ev.clientX-wr.left+14,wr.width-270)+"px";tip.style.top=(ev.clientY-wr.top+10)+"px"}; + svg.onmouseleave=function(){tip.style.display="none"}; + var lg="",present={},othChains={};data.forEach(function(d){for(var k=0;k0)othChains[c]=1})}); + Object.keys(present).forEach(function(s){ + var label=NAMES[s]; + // Name what "Other" holds - the fold is visual, never informational. + if(String(s)==="8"&&Object.keys(othChains).length)label+=" ("+Object.keys(othChains).sort().map(chainName).join(", ")+")"; + lg+=''+label+""}); + // An empty series under a wire filter is a real answer, not a broken + // chart - say which filter emptied it rather than showing a blank grid. + if(!Object.keys(present).length){ + lg='no '+(state.metric==="buyers"?"buyers":state.traffic==="free"?"free-tier calls": + (state.wire==="all"?"":state.wire==="mpp"?"MPP-wire ":"x402-wire ")+ + (state.scope==="ext"?"external":state.scope==="int"?"internal":"")+" settlements")+' in this window'} + document.getElementById("rvzLegend").innerHTML=lg; + // The chart defaults to External, which is right - the canary is our own + // money recycling and must never inflate revenue. But that makes "no + // canary today" and "canary hidden by the current filter" look identical, + // and someone reading the default view reasonably concluded the canary + // had stopped running when it had settled on 11 of 12 rails that day. + // + // So when internal settlements EXIST in the window and are filtered out, + // say so and name the control that shows them. The number on the chart + // does not change; only the reader's ability to tell absence from + // concealment does. + (function(){ + var el=document.getElementById("rvzScopeNote"); + if(!el)return; + if(state.scope!=="ext"){el.style.display="none";return} + var days={},chains={}; + state.rows.forEach(function(r){ + if((r.intTx||0)>0){days[r.day]=1;chains[r.chain]=1} + }); + var nd=Object.keys(days).length, nc=Object.keys(chains).length; + if(!nd){el.style.display="none";return} + el.style.display=""; + el.textContent="Showing external settlements only. "+nc+" chain"+(nc===1?"":"s")+ + " also settled internal (canary) transactions on "+nd+" day"+(nd===1?"":"s")+ + " in this window - select \u201cInternal (canary)\u201d or \u201cBoth\u201d to see them. "+ + "They are excluded from revenue on purpose: the canary buys from us with our own wallet."; + })(); + // "Other" gets one muted sub-column per folded chain (a subset of the + // Other column, so they never add to the row total). + var othList=Object.keys(othChains).sort(); + var tb='';Object.keys(present).forEach(function(s){tb+=""; + if(String(s)==="8")othList.forEach(function(c){tb+='"})});tb+=""; + data.forEach(function(d){var tot=0;tb+="";Object.keys(present).forEach(function(s){var v=d.slots[s]||0;tot+=v;tb+=""; + if(String(s)==="8")othList.forEach(function(c){tb+='"})});tb+=""}); + document.getElementById("rvzTable").innerHTML=tb+"
    day"+NAMES[s]+"· '+chainName(c)+"total
    "+d.day+""+fmt(v)+"'+fmt((d.oth||{})[c]||0)+""+fmt(tot)+"
    "; + } + function buyersTrend(){ + // Rolling recent-vs-prior comparison (last 14 days vs the 14 before + // that), not a lifetime first-half/second-half split - stays + // meaningful as history grows, rather than diluting toward flat as + // more old days accumulate. Needs 28 days of real data to say + // anything; thin history omits the line entirely rather than + // asserting a trend from a handful of points (found in an internal + // audit, 2026-08-16: buyer diversity fell 45% over 60 days, + // independent of any single wallet - this makes that visible without + // eyeballing the chart). + var rows=(state.buyers||[]).slice().sort(function(a,b){return a.day=5?"up":"flat"; + var arrow=dir==="down"?"↓":dir==="up"?"↑":"→"; + trendTxt=" "+arrow+" Last 14 days averaged "+t.recent.toFixed(1)+" distinct buyers/day, "+ + (dir==="flat"?"about the same as":dir+" "+Math.abs(t.pct).toFixed(0)+"% from")+ + " the 14 days before that ("+t.prior.toFixed(1)+"/day)."; + } + el.textContent="Distinct external wallets that settled a payment. Someone paying on two chains in one day is one buyer, and the cumulative line is a running union rather than a sum."+ + (c&&c.buyers?" Over this window: "+c.buyers+" buyers, "+c.payments+" payments, biggest single wallet "+c.topSharePct+"% of them and the top five "+c.top5SharePct+"%.":"")+ + trendTxt; + } + function settleNote(){ + var el=document.getElementById("rvzSettleNote"); + el.style.display=state.settle==="all"?"none":"block"; + } + function freeNote(){ + var el=document.getElementById("rvzFreeNote"); + if(state.traffic==="paid"){el.style.display="none";return} + el.style.display="block"; + el.textContent="Free calls are served via proof-of-work and settle nowhere, so they earn $0 and appear only under Transactions."+ + (state.freeSince?" Per-day recording of the free tier began "+state.freeSince+ + " - earlier days have no per-day record, which is not the same as no free traffic.": + " Per-day recording of the free tier has not started yet.")+ + " Internal heartbeat probes are excluded."; + } + seg("rvzMode",function(v){state.mode=v}); + seg("rvzMetric",function(v){state.metric=v; + // Revenue $ is paid-only: a free call has no dollar value to chart. + if(v!=="tx"&&state.traffic!=="paid"){state.traffic="paid";setSeg("rvzTraffic","paid")} + // Buyers counts settled external wallets, so the internal/canary scope, + // the wire split, and the settled-to lane do not apply to it. + if(v==="buyers"){if(state.scope!=="ext"){state.scope="ext";setSeg("rvzScope","ext")} + if(state.wire!=="all"){state.wire="all";setSeg("rvzWire","all")} + if(state.settle!=="all"){state.settle="all";setSeg("rvzSettle","all");settleNote()}} + freeNote();buyersNote()}); + seg("rvzScope",function(v){state.scope=v; + if(state.metric==="buyers"){state.metric="tx";setSeg("rvzMetric","tx");buyersNote()}}); + seg("rvzTraffic",function(v){state.traffic=v; + if(v!=="paid"&&state.metric!=="tx"){state.metric="tx";setSeg("rvzMetric","tx")} + if(v!=="paid"&&state.settle!=="all"){state.settle="all";setSeg("rvzSettle","all");settleNote()} + freeNote();buyersNote()}); + seg("rvzWire",function(v){state.wire=v; + // The MPP subset and the SOR subset are not tracked as an intersection - + // composing them would fabricate numbers, so they are mutually exclusive. + if(v!=="all"&&state.settle!=="all"){state.settle="all";setSeg("rvzSettle","all");settleNote()} + document.getElementById("rvzWireNote").style.display=v==="all"?"none":"block"}); + seg("rvzSettle",function(v){state.settle=v; + if(v!=="all"){ + if(state.wire!=="all"){state.wire="all";setSeg("rvzWire","all");document.getElementById("rvzWireNote").style.display="none"} + // The SOR lane is settled revenue - the free tier settles nowhere. + if(state.traffic!=="paid"){state.traffic="paid";setSeg("rvzTraffic","paid");freeNote()} + if(state.metric==="buyers"){state.metric="tx";setSeg("rvzMetric","tx");buyersNote()} + } + settleNote()}); + // The free-tier series is a separate endpoint (free calls never touch the + // settlement ledger). A failure there must not blank the revenue chart, so + // it degrades to an empty free lane rather than rejecting the pair. + Promise.all([ + fetch("/api/revenue/daily").then(function(r){return r.json()}), + fetch("/api/calls/daily").then(function(r){return r.json()}).catch(function(){return{days:[],recordingSince:null}}) + ]).then(function(res){ + state.rows=res[0].days||[]; + state.free=res[1].days||[]; + state.freeSince=res[1].recordingSince||null; + state.buyers=res[0].buyers||[]; + state.conc=res[0].concentration||null; + render(); + }).catch(function(){document.querySelector(".rvz-wrap").innerHTML='
    series unavailable
    '}); + })(); diff --git a/assets/js/sdk-playground.js b/assets/js/sdk-playground.js new file mode 100644 index 00000000..f4385403 --- /dev/null +++ b/assets/js/sdk-playground.js @@ -0,0 +1,190 @@ +(function(){ + var EXAMPLES=[ + { + label: "Hash a string", + code: "// Hash text with SHA-256\nconst result = await callTool(\"hash\", {\n text: \"hello world\",\n algo: \"sha256\"\n});\nconsole.log(result);", + }, + { + label: "Find tools by keyword", + code: "// Search for tools matching a query - /api/find is free and\n// unpaywalled, so this skips the proof-of-work step entirely.\nconst result = await callTool(\"find\", {\n q: \"geocode\"\n}, { path: \"/api/find\", method: \"GET\", free: true });\nconsole.log(result);", + }, + { + label: "Generate a UUID", + code: "// Generate a v4 UUID\nconst result = await callTool(\"uuid\", {}, { path: \"/api/uuid\", method: \"GET\" });\nconsole.log(result);", + }, + { + label: "Convert units", + code: "// Convert miles to kilometers\nconst result = await callTool(\"unit-convert\", {\n value: 26.2,\n from: \"miles\",\n to: \"kilometers\"\n});\nconsole.log(result);", + }, + { + label: "Base64 encode", + code: "// Encode text to base64\nconst result = await callTool(\"base64\", {\n text: \"Agent402 is awesome\"\n});\nconsole.log(result);", + }, + ]; + var wrapEl=document.querySelector('.sp-wrap'); + var BASE=wrapEl?(wrapEl.getAttribute('data-base')||''):''; + var codeEl=document.getElementById('spCode'); + var resultEl=document.getElementById('spResult'); + var runBtn=document.getElementById('spRun'); + var statusEl=document.getElementById('spStatus'); + var exBtns=document.querySelectorAll('.sp-example'); + + exBtns.forEach(function(btn){ + btn.addEventListener('click',function(){ + exBtns.forEach(function(b){b.classList.remove('active');}); + btn.classList.add('active'); + var idx=parseInt(btn.getAttribute('data-idx'),10); + codeEl.value=EXAMPLES[idx].code; + clearResult(); + }); + }); + + function clearResult(){ + while(resultEl.firstChild)resultEl.removeChild(resultEl.firstChild); + resultEl.textContent='Click Run to execute'; + } + + function addLine(cls,text){ + var line=document.createElement('div'); + line.className=cls; + line.textContent=text; + resultEl.appendChild(line); + } + + /* --- real PoW + tool call, run in this TRUSTED page (never inside the + sandbox iframe below - it has no network access at all). --- */ + async function sha256(msg){ + var buf=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(msg)); + return new Uint8Array(buf); + } + function leadingZeroBits(buf){ + var n=0; + for(var i=0;i=difficulty)return nonce; + nonce++; + if(nonce%5000===0)await new Promise(function(r){setTimeout(r,0);}); + } + } + + async function callTool(slug,params,opts){ + opts=opts||{}; + var path=opts.path||('/api/'+slug); + var method=opts.method||'POST'; + var headers={}; + + if(opts.free){ + // Free, unpaywalled endpoints (e.g. /api/find) need no proof-of-work - + // requesting a PoW challenge for a slug outside the paid catalog 404s. + while(statusEl.firstChild)statusEl.removeChild(statusEl.firstChild); + statusEl.appendChild(document.createTextNode('Calling free endpoint...')); + }else{ + while(statusEl.firstChild)statusEl.removeChild(statusEl.firstChild); + var spin=document.createElement('span'); + spin.className='spin'; + statusEl.appendChild(spin); + statusEl.appendChild(document.createTextNode(' Solving PoW...')); + + var cRes=await fetch(BASE+'/api/pow/challenge?slug='+encodeURIComponent(slug)); + if(!cRes.ok)throw new Error('challenge request failed: '+cRes.status); + var cData=await cRes.json(); + var nonce=await solvePow(cData.challenge,cData.difficulty); + headers['X-Pow-Solution']=cData.token+':'+nonce; + + while(statusEl.firstChild)statusEl.removeChild(statusEl.firstChild); + var spin2=document.createElement('span'); + spin2.className='spin'; + statusEl.appendChild(spin2); + statusEl.appendChild(document.createTextNode(' Calling tool...')); + } + + var resp; + if(method==='GET'){ + resp=await fetch(BASE+path+'?'+new URLSearchParams(params),{headers:headers}); + }else{ + headers['Content-Type']='application/json'; + resp=await fetch(BASE+path,{method:'POST',headers:headers,body:JSON.stringify(params)}); + } + var ct=resp.headers.get('content-type')||''; + if(ct.indexOf('json')!==-1)return resp.json(); + return resp.text(); + } + + /* --- user code runs inside an isolated, network-less sandbox iframe + (assets/sdk-sandbox.html, served from its own route with its own + relaxed CSP - see /sdk-playground/sandbox in server.js). The iframe can + only reach the outside world by asking THIS page to run callTool on its + behalf via postMessage; it never gets fetch, cookies, or this origin. --- */ + var RUN_TIMEOUT_MS=30000; + var active=null; // { iframe, timer, resolveReady } + + function teardown(){ + if(!active)return; + clearTimeout(active.timer); + if(active.iframe && active.iframe.parentNode)active.iframe.parentNode.removeChild(active.iframe); + active=null; + } + + window.addEventListener('message',function(ev){ + if(!active || ev.source!==active.iframe.contentWindow)return; + var msg=ev.data; + if(!msg||typeof msg!=='object')return; + + if(msg.type==='ready'){ + active.iframe.contentWindow.postMessage({type:'run',code:active.code},'*'); + return; + } + if(msg.type==='log'){ + addLine('log',msg.text); + return; + } + if(msg.type==='error'){ + addLine('err','Error: '+msg.message); + return; + } + if(msg.type==='callTool'){ + var id=msg.id; + callTool(msg.slug,msg.params,msg.opts).then(function(result){ + if(active && active.iframe.contentWindow)active.iframe.contentWindow.postMessage({type:'callToolResult',id:id,result:result},'*'); + }).catch(function(e){ + if(active && active.iframe.contentWindow)active.iframe.contentWindow.postMessage({type:'callToolError',id:id,error:(e&&e.message)||String(e)},'*'); + }); + return; + } + if(msg.type==='done'){ + statusEl.textContent='Done'; + runBtn.disabled=false; + teardown(); + return; + } + }); + + runBtn.addEventListener('click',function(){ + if(active)return; + runBtn.disabled=true; + while(resultEl.firstChild)resultEl.removeChild(resultEl.firstChild); + statusEl.textContent=''; + + var iframe=document.createElement('iframe'); + iframe.setAttribute('sandbox','allow-scripts'); + iframe.style.display='none'; + iframe.src='/sdk-playground/sandbox'; + document.body.appendChild(iframe); + + active={iframe:iframe,code:codeEl.value,timer:setTimeout(function(){ + addLine('err','Error: timed out after '+(RUN_TIMEOUT_MS/1000)+'s'); + statusEl.textContent='Error'; + runBtn.disabled=false; + teardown(); + },RUN_TIMEOUT_MS)}; + }); +})(); diff --git a/assets/js/site-chrome.js b/assets/js/site-chrome.js new file mode 100644 index 00000000..977dd17c --- /dev/null +++ b/assets/js/site-chrome.js @@ -0,0 +1,99 @@ +// Shared, site-wide chrome behavior: the hamburger menu toggle and +// reveal-on-scroll. Every page rendered through ledgerShell() loads this +// (CSP hardening, 2026-08-16 - was two inline + + diff --git a/package-lock.json b/package-lock.json index 7d0a137f..9665f9f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "@mozilla/readability": "^0.6.0", "@payai/facilitator": "^2.4.4", "@sentry/node": "^10.70.0", - "@x402/avm": "^2.18.0", - "@x402/evm": "^2.16.0", - "@x402/express": "^2.16.0", - "@x402/fetch": "^2.16.0", - "@x402/stellar": "^2.21.0", - "@x402/svm": "^2.16.0", + "@x402/avm": "^2.22.0", + "@x402/evm": "^2.22.0", + "@x402/express": "^2.22.0", + "@x402/fetch": "^2.22.0", + "@x402/stellar": "^2.22.0", + "@x402/svm": "^2.22.0", "@zxing/library": "^0.21.3", "algosdk": "^3.6.0", "better-sqlite3": "^12.11.1", @@ -2789,55 +2789,46 @@ "license": "MIT" }, "node_modules/@x402/avm": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@x402/avm/-/avm-2.18.0.tgz", - "integrity": "sha512-vnqUzm9h+6yI9xPpd9Iu7LBO8+qKcNA4RIOvy3dAP6zGGG8ij/Cv1Vt9yLoXHAlsIpYqezFZ7m/9o7hUzlpaCw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/avm/-/avm-2.22.0.tgz", + "integrity": "sha512-LVFCkSPKA5F5/TrKHX+gsMKCznDEJnbpXD9efXENwgyhrt61Zx/AIIQObQ6aiWKkZJiF7dKrHIym6JwlEMf1hA==", "license": "Apache-2.0", "dependencies": { "@algorandfoundation/algokit-utils": "10.0.0-alpha.46", - "@x402/core": "~2.18.0" - } - }, - "node_modules/@x402/avm/node_modules/@x402/core": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.18.0.tgz", - "integrity": "sha512-3LB5m0Yx7C38ks8jDqTGYPZ2FnLzlH9pTlGvE8er2ujS1ri12sXXvWwnmYsmh3ZkXSDbV3BKU8oRKULatPp0Hg==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.24.2" + "@x402/core": "~2.22.0" } }, "node_modules/@x402/core": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.16.0.tgz", - "integrity": "sha512-7MjwOEiE6ICAYtOsK9vQl66Ho4jERDuuoRg/No2Nc2GQ7y/M/yu4DJ65LzeBycQE+Qrg2RqQG7KR51tyT+hzjQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.22.0.tgz", + "integrity": "sha512-hYySs/PvukqRipC2mKqrsn/jx2U/p5EGnN3clQ2CIcApwtoFuG77CqpC3Wy7Fdp/juW8t8quiPVnqGuIkfix1Q==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@x402/evm": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.16.0.tgz", - "integrity": "sha512-DGcwATWosIb3hmO/CfWASrn+xbxp7KQyzmMPcIW23XuDjo9YPVRElZWtHBur3euakdM021Hl8SPF1zR0G+W4hw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.22.0.tgz", + "integrity": "sha512-WzZVGVx6B2cvCAEHDCuNWfQkcwIZsOVdGcJKILmG/ySzLpBZ1/ARU2kU/8K3ekxhygAKrkarltVVznSEoO5vdQ==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0", + "@x402/core": "~2.22.0", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@x402/express": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.16.0.tgz", - "integrity": "sha512-M3kAOy1oxHEsVVMNbuLhhb3S00p+O0k5T/O4f/m1D5txQrEpL4sQUHul9iVehp3a7K8uVMstkTyCJ+LO44HTtQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.22.0.tgz", + "integrity": "sha512-KWgFOuPYGGGL57qpeXyQ1bAvHSewOH0zBbZQ2gPbE+rkftvyf5AoVi+6vbohXnaBtyTUDgeg0VMMNN+at+rsQw==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0", - "@x402/extensions": "~2.16.0" + "@x402/core": "~2.22.0", + "@x402/extensions": "~2.22.0" }, "peerDependencies": { - "@x402/paywall": "^2.16.0", + "@x402/paywall": "^2.22.0", "express": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2847,15 +2838,15 @@ } }, "node_modules/@x402/extensions": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.16.0.tgz", - "integrity": "sha512-LHh5QrvB1QwiPr2zFqzpJonmMGxqJq1nDHifihyqNx1lJvU3DQgYGxPuHXE2l6nxGG0chF0alA/BsidDHFie/A==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.22.0.tgz", + "integrity": "sha512-E5Ma1Fs1GwO/ET7KtGUI7dPuVFoINR+mwTWVoGdNi3DsJBJKDWSA7Ta7bDgSANzV3XCAMK9gvz/VSItZliApMA==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.0", "@scure/base": "^1.2.6", "@signinwithethereum/siwe": "^4.1.0", - "@x402/core": "~2.16.0", + "@x402/core": "~2.22.0", "ajv": "^8.17.1", "jose": "^5.9.6", "tweetnacl": "^1.0.3", @@ -2873,46 +2864,37 @@ } }, "node_modules/@x402/fetch": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.16.0.tgz", - "integrity": "sha512-skW0XsWzi4NIfqCrY6KxhO3UEJ8qh4nBIKIgwWFCXFHNqRKYWZ7qcCI/DHy7cy3jdYs1MV4PoXX24rP3Umca1Q==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.22.0.tgz", + "integrity": "sha512-+uMCPGXw1h7YGjS778YnwUi7YvZ5Ny8XeFjBL8TvQuy9T+j8O5x/zDQu9nyNaOa/OppTqVh+CkpW+xs17ePyXg==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0" + "@x402/core": "~2.22.0" } }, "node_modules/@x402/stellar": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.21.0.tgz", - "integrity": "sha512-Kuq1C0OJB+K8caDinXGRhUYQYBaJnv2flxho5swARe/8tRoDrDCckV5hz8wN0Lje/Nyc5v6O04WpZdHNZZpFTw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.22.0.tgz", + "integrity": "sha512-nih0BKl+6EaWrfaocm9/uu1YPfGS+QzmjI9ZY/nNbm05Z2las92rDqxqYWVXEeWz0QNtav3E3w45ctBpTvbakg==", "license": "Apache-2.0", "dependencies": { "@stellar/stellar-sdk": "^16.0.1", - "@x402/core": "~2.21.0" + "@x402/core": "~2.22.0" }, "engines": { "node": ">=22.0.0" } }, - "node_modules/@x402/stellar/node_modules/@x402/core": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.21.0.tgz", - "integrity": "sha512-0djKE7V5/JKDMrjRe5he3DoMFzlbVnUcvMmLAb2j6OoAJDamupkFh6fFrXeoHwjkBIxOFUzjGI4FVixz2dMxSA==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.24.2" - } - }, "node_modules/@x402/svm": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.16.0.tgz", - "integrity": "sha512-hxggK4PYjfI5CdsbDeVe9JDS70uPMlfFrpOaEPBo5aQ/wtFq/7ydkiUxKXozlhRw9KFqOue9zUGrPsHLCokTTw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.22.0.tgz", + "integrity": "sha512-io8JqfZncn9BonlcrVjsuLp8nhIwc97qY7ceI6gLxztxuEb2J37nNqbAOU9Z2u/2eQc/nrJCyGirfZcFL6JGeA==", "license": "Apache-2.0", "dependencies": { "@solana-program/compute-budget": "^0.11.0", "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1", - "@x402/core": "~2.16.0" + "@x402/core": "~2.22.0" }, "peerDependencies": { "@solana/kit": ">=5.1.0" diff --git a/package.json b/package.json index 7170a756..5c5c2fe7 100644 --- a/package.json +++ b/package.json @@ -64,12 +64,12 @@ "@mozilla/readability": "^0.6.0", "@payai/facilitator": "^2.4.4", "@sentry/node": "^10.70.0", - "@x402/avm": "^2.18.0", - "@x402/evm": "^2.16.0", - "@x402/express": "^2.16.0", - "@x402/fetch": "^2.16.0", - "@x402/stellar": "^2.21.0", - "@x402/svm": "^2.16.0", + "@x402/avm": "^2.22.0", + "@x402/evm": "^2.22.0", + "@x402/express": "^2.22.0", + "@x402/fetch": "^2.22.0", + "@x402/stellar": "^2.22.0", + "@x402/svm": "^2.22.0", "@zxing/library": "^0.21.3", "algosdk": "^3.6.0", "better-sqlite3": "^12.11.1", diff --git a/scripts/mcp-find-latest-version.js b/scripts/mcp-find-latest-version.js new file mode 100644 index 00000000..00f44ad1 --- /dev/null +++ b/scripts/mcp-find-latest-version.js @@ -0,0 +1,23 @@ +// Given the MCP Registry's `GET /v0/servers?search=...` response on stdin, +// prints the version currently marked isLatest (nothing if none found or the +// input doesn't parse). Used by the publish job in deploy.yml: right before +// publishing a new version, it captures the version that's ABOUT to become +// stale, so it can be deprecated immediately after the new one goes live - +// otherwise every publish leaves its predecessor "active" forever, which is +// exactly how 24 of 25 published versions ended up non-deprecated (found +// 2026-08-16, first noticed via a growth/revenue audit). +let input = ""; +process.stdin.on("data", (chunk) => { input += chunk; }); +process.stdin.on("end", () => { + try { + const data = JSON.parse(input); + const row = (data.servers || []).find( + (s) => s?._meta?.["io.modelcontextprotocol.registry/official"]?.isLatest === true + ); + if (row?.server?.version) process.stdout.write(String(row.server.version)); + } catch { + // Any parse/shape surprise -> empty output. The caller (deploy.yml) treats + // a blank result as "nothing to deprecate" and moves on - a registry + // hiccup here must never fail the publish job over a best-effort cleanup. + } +}); diff --git a/scripts/test-algorand-page.js b/scripts/test-algorand-page.js index 7f7d4944..432f32c9 100644 --- a/scripts/test-algorand-page.js +++ b/scripts/test-algorand-page.js @@ -75,8 +75,8 @@ ok(html.includes("SELLERS") && html.includes("PRICE FLOOR"), "stats strip render ok(html.includes("THIS HOST"), "local seller card carries the THIS HOST tag"); ok(html.includes("ext1.example"), "seller card shows the hostname"); -// Self-serve form present -ok(html.includes('id="list-api"') && html.includes("/api/index/register"), "List your API form renders"); +// Self-serve form present (submit handler is external — CSP hardening, 2026-08-16) +ok(html.includes('id="list-api"') && html.includes(''), "List your API form renders"); // Activity section: cards + bars + honesty captions const buckets = Array.from({ length: 30 }, (_, i) => ({ date: "2026-06-" + String(11 + (i % 20)).padStart(2, "0"), tx: i === 29 ? 5 : 0, usd: i === 29 ? 0.05 : 0, buyers: i === 29 ? 2 : 0 })); diff --git a/scripts/test-burger-aria-label.js b/scripts/test-burger-aria-label.js new file mode 100644 index 00000000..79b0b171 --- /dev/null +++ b/scripts/test-burger-aria-label.js @@ -0,0 +1,45 @@ +// Locks the hamburger menu aria-label fix (2026-08-16 audit): a402ToggleMenu() +// already correctly toggled aria-expanded, but never touched aria-label, +// which stayed "Open menu" permanently even while the menu was open - a +// screen reader user activating it never heard confirmation the menu opened, +// and had no way to know the SAME control now closes it. +// +// Verified via a real browser (Playwright) clicking the actual button, not a +// static check that both label strings exist somewhere in the source. +// +// Requires a booted server (same TARGET_URL convention as other page tests): +// FREE_MODE=true PORT=3000 node src/server.js +// TARGET_URL=http://localhost:3000 node scripts/test-burger-aria-label.js +import { chromium } from "playwright"; + +const BASE = process.env.TARGET_URL || "http://localhost:3000"; +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); // burger only shows below the collapse breakpoint + await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }); + + const burger = page.locator(".ml-burger"); + ok(await burger.count() === 1, "the hamburger button is present at mobile width"); + + const before = await burger.evaluate((el) => ({ label: el.getAttribute("aria-label"), expanded: el.getAttribute("aria-expanded") })); + ok(before.label === "Open menu", `closed state: aria-label is "Open menu" (got "${before.label}")`); + ok(before.expanded === "false", `closed state: aria-expanded is "false" (got "${before.expanded}")`); + + await burger.click(); + const opened = await burger.evaluate((el) => ({ label: el.getAttribute("aria-label"), expanded: el.getAttribute("aria-expanded") })); + ok(opened.label === "Close menu", `open state: aria-label flips to "Close menu" (got "${opened.label}")`); + ok(opened.expanded === "true", `open state: aria-expanded flips to "true" (got "${opened.expanded}")`); + + await burger.click(); + const closed = await burger.evaluate((el) => ({ label: el.getAttribute("aria-label"), expanded: el.getAttribute("aria-expanded") })); + ok(closed.label === "Open menu", `closed again: aria-label flips back to "Open menu" (got "${closed.label}")`); + ok(closed.expanded === "false", `closed again: aria-expanded flips back to "false" (got "${closed.expanded}")`); +} finally { + await browser.close(); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-buyers-trend.js b/scripts/test-buyers-trend.js new file mode 100644 index 00000000..b259fe5b --- /dev/null +++ b/scripts/test-buyers-trend.js @@ -0,0 +1,96 @@ +// Unit tests for /revenue's buyersTrend() - the rolling 14-day recent-vs- +// prior buyer-diversity comparison added after an internal audit found +// distinct daily buyers fell 45% over 60 days, independent of any single +// wallet, with nothing on the page surfacing that trend directly (a viewer +// had to eyeball the chart). Extracts the actual function source and +// executes it for real - not a regex/string check - since this is genuine +// arithmetic, not just DOM structure. Lives in assets/js/revenue-chart.js +// (external file, CSP hardening, 2026-08-16) - was inline in +// src/revenue-live.js's shared ') && catalogSearchScript.includes("addEventListener('input'"), "live search filter script is present and wired to the input event, not a form submit"); ok(!html.includes("onSubmit"), "no form-submit-to-raw-JSON handler ships (the design mockup's behavior, deliberately not ported)"); // --- structured data -------------------------------------------------------- diff --git a/scripts/test-compare-scroll.js b/scripts/test-compare-scroll.js new file mode 100644 index 00000000..d08ce501 --- /dev/null +++ b/scripts/test-compare-scroll.js @@ -0,0 +1,55 @@ +// Locks the scroll-wrapper fix for /compare (2026-08-16 audit): its four +// tables were the only ones site-wide with no horizontal-scroll wrapper - +// every other table (leaderboard, status, revenue) already follows this +// codebase's own documented pattern (
    ). At mobile widths the +// unwrapped tables would clip content or force the WHOLE PAGE to scroll +// sideways instead of just the table. +// +// Verified via a real browser (Playwright) at a genuinely narrow viewport - +// a static check that the wrapper div/CSS rule exists in the source can't +// prove the table actually becomes scrollABLE (min-width vs viewport width +// is an empirical fact, not something regex can compute) or that the PAGE +// itself stays contained rather than scrolling horizontally too. +// +// Requires a booted server (same TARGET_URL convention as other page tests): +// FREE_MODE=true PORT=3000 node src/server.js +// TARGET_URL=http://localhost:3000 node scripts/test-compare-scroll.js +import { chromium } from "playwright"; + +const BASE = process.env.TARGET_URL || "http://localhost:3000"; +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); // genuinely narrow mobile + await page.goto(`${BASE}/compare`, { waitUntil: "load" }); + + const wraps = await page.locator(".cmp-scroll").count(); + ok(wraps === 4, `/compare has 4 scroll wrappers, one per table (got ${wraps})`); + + for (let i = 0; i < wraps; i++) { + const el = page.locator(".cmp-scroll").nth(i); + const table = el.locator("table.cmp-table"); + ok(await table.count() === 1, `wrapper ${i}: contains exactly one .cmp-table`); + const { scrollWidth, clientWidth, overflowX } = await el.evaluate((e) => ({ scrollWidth: e.scrollWidth, clientWidth: e.clientWidth, overflowX: getComputedStyle(e).overflowX })); + ok(scrollWidth > clientWidth, `wrapper ${i}: table is genuinely wider than its container at 390px (scrollWidth=${scrollWidth} > clientWidth=${clientWidth})`); + // The direct, meaningful check: scrollWidth > clientWidth alone only + // proves the content is bigger than the box - that's true whether or + // not overflow is actually enabled. overflow-x must be auto/scroll + // specifically, or the "wider content" fact above means nothing (the + // page also has a SITE-WIDE `html { overflow-x: clip }` guard - + // ledger-chrome.js:59 - that masks a missing wrapper rule by silently + // clipping the overflow instead of showing a scrollbar, so checking + // page-level overflow can never catch a regression in this specific + // wrapper; checked directly against ledger-chrome.js's source to + // confirm this guard is real, not assumed). + ok(overflowX === "auto" || overflowX === "scroll", `wrapper ${i}: overflow-x is actually auto/scroll, not just visually wide (got "${overflowX}")`); + } +} finally { + await browser.close(); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-csp-violations.js b/scripts/test-csp-violations.js new file mode 100644 index 00000000..586913d6 --- /dev/null +++ b/scripts/test-csp-violations.js @@ -0,0 +1,212 @@ +// Sitewide CSP regression test — the payoff of the 2026-08-16 CSP hardening +// pass (script-src dropped 'unsafe-inline'; every page-behavior script now +// lives in a real file under /js/:file or a dedicated scoped-CSP route). +// A static grep for "'), "the page references the external sidebar script"); +ok(docsSidebarScript.includes("ml-docs-search-input"), "the sidebar script targets the real input id"); +ok(docsSidebarScript.includes("ml-docs-mobile-toggle"), "the sidebar script also wires the mobile toggle"); // --- docsLayoutHtml is the single source of the wrapper markup - both // callers must produce byte-identical structure for the same slug ----------- diff --git a/scripts/test-faint-contrast.js b/scripts/test-faint-contrast.js new file mode 100644 index 00000000..42eaa4ae --- /dev/null +++ b/scripts/test-faint-contrast.js @@ -0,0 +1,59 @@ +// Locks the WCAG AA contrast fix for --faint (2026-08-16 audit): the token +// was #6C6C68, 3.15-3.66:1 against the dark surfaces it's actually +// composited on in shared nav/footer chrome (used at 10-13px - normal text +// needs 4.5:1, not the relaxed 3:1 large-text threshold) - a sitewide +// failure since ledger-chrome.js's :root is the ONE definition every page +// inherits. Offline - reads the token + background hexes straight out of +// the CSS source and computes real WCAG relative-luminance contrast, so a +// future value change is caught by the math, not just a hardcoded string +// compare. +import { readFileSync } from "node:fs"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const src = readFileSync(new URL("../src/ledger-chrome.js", import.meta.url), "utf8"); + +function tokenValue(name) { + const m = src.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`)); + return m ? m[1] : null; +} + +function relLum(hex) { + const c = hex.replace("#", ""); + const r = parseInt(c.slice(0, 2), 16) / 255, g = parseInt(c.slice(2, 4), 16) / 255, b = parseInt(c.slice(4, 6), 16) / 255; + const lin = (x) => (x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} +function contrast(hex1, hex2) { + const L1 = relLum(hex1), L2 = relLum(hex2); + const lighter = Math.max(L1, L2), darker = Math.min(L1, L2); + return (lighter + 0.05) / (darker + 0.05); +} + +const faint = tokenValue("faint"); +ok(!!faint, `found --faint in ledger-chrome.js's :root (got ${faint})`); + +// Every dark surface --faint text is actually composited on across the +// site's shared chrome + page bodies (paper/card/card-zebra/footer-bg). +const SURFACES = { paper: "paper", card: "card", "card-zebra": "card-zebra", "footer-bg": "footer-bg" }; +for (const [label, tokenName] of Object.entries(SURFACES)) { + const bg = tokenValue(tokenName); + ok(!!bg, `found --${tokenName} token (got ${bg})`); + if (!bg || !faint) continue; + const ratio = contrast(faint, bg); + // WCAG AA for normal-size text (< 18pt/24px, or < 14pt/18.66px bold) is + // 4.5:1 - --faint is used at 10-13px throughout, well under that. + ok(ratio >= 4.5, `--faint (${faint}) on --${tokenName} (${bg}) clears WCAG AA 4.5:1 (got ${ratio.toFixed(2)}:1)`); +} + +// Sanity: --faint must stay visually distinct from --muted (the "one level +// up" token) - the fix should not just collapse the two into the same shade. +const muted = tokenValue("muted"); +if (faint && muted) { + const distinctness = contrast(faint, muted); + ok(distinctness > 1.05, `--faint (${faint}) stays visually distinct from --muted (${muted}) (contrast ${distinctness.toFixed(2)}:1)`); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-focus-visible.js b/scripts/test-focus-visible.js new file mode 100644 index 00000000..3e44a086 --- /dev/null +++ b/scripts/test-focus-visible.js @@ -0,0 +1,50 @@ +// Locks the focus-visible fix (2026-08-16 audit): four prominent search/demo +// inputs stripped outline:none with no replacement, unlike 7 other inputs in +// the same codebase that already use a :focus{border-color:var(--accent)} +// pattern. Verified via a REAL browser (Playwright) - a lesson learned the +// hard way earlier the same day: static regex on the source text cannot +// prove a CSS rule actually applies (see test-css-tokens-resolve.js's own +// header comment for the incident this guards against generalizing). +// +// Requires a booted server (same TARGET_URL convention as other page tests): +// FREE_MODE=true PORT=3000 node src/server.js +// TARGET_URL=http://localhost:3000 node scripts/test-focus-visible.js +import { chromium } from "playwright"; + +const BASE = process.env.TARGET_URL || "http://localhost:3000"; +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage(); + + // Each case: [page path, input selector, element to read the border color + // from (the input itself, or its focus-within wrapper)]. + const CASES = [ + { path: "/tools", input: "#cat-search", borderEl: ".cat-search-wrap", label: "/tools catalog search" }, + { path: "/", input: "#hm-demo-in", borderEl: "#hm-demo-in", label: "/ homepage PoW demo input" }, + { path: "/marketplace", input: "form.mkt-search-wrap input[name='q']", borderEl: "form.mkt-search-wrap", label: "/marketplace search" }, + { path: "/base", input: "form.mkt-search-wrap input[name='q']", borderEl: "form.mkt-search-wrap", label: "/base (per-chain) search" }, + ]; + + for (const c of CASES) { + await page.goto(`${BASE}${c.path}`, { waitUntil: "load" }); + const input = page.locator(c.input).first(); + const borderTarget = page.locator(c.borderEl).first(); + ok(await input.count() > 0, `${c.label}: input is present`); + const before = await borderTarget.evaluate((el) => getComputedStyle(el).borderColor); + await input.focus(); + const after = await borderTarget.evaluate((el) => getComputedStyle(el).borderColor); + ok(before !== after, `${c.label}: border color visibly changes on focus (${before} -> ${after})`); + // The accent color is #F0522E = rgb(240, 82, 46) - confirm it's THAT + // color specifically, not just "some change" (e.g. an unrelated hover + // rule coincidentally firing). + ok(after === "rgb(240, 82, 46)", `${c.label}: focused border is the accent color specifically (got ${after})`); + } +} finally { + await browser.close(); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-llms-mpp-header-note.js b/scripts/test-llms-mpp-header-note.js new file mode 100644 index 00000000..dd1f97c4 --- /dev/null +++ b/scripts/test-llms-mpp-header-note.js @@ -0,0 +1,40 @@ +// Locks the "how to read our 402" note added to llms.txt (2026-08-16 audit, +// prompted by issue #794): an external client hard-failed with +// "no_supported_rail" because it only recognized the MPP `WWW-Authenticate: +// Payment` scheme and never checked for the real x402 `PAYMENT-REQUIRED` +// header present on the SAME response. Mike's fix in the issue thread was a +// one-off reply; this locks the same guidance into the machine-readable +// surface so the next client author sees it before hitting the same wall. +// +// Offline - calls llmsTxt() directly with a minimal fixture catalog. +// +// node scripts/test-llms-mpp-header-note.js +import { llmsTxt } from "../src/seo.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const FIXTURE_CATALOG = { + "POST /api/hash": { + slug: "hash", name: "Hash", category: "encoding", price: "$0.001", + description: "Compute a cryptographic hash.", + discovery: { inputSchema: { properties: {}, required: [] }, input: {} }, + }, +}; + +const text = llmsTxt("https://agent402.tools", FIXTURE_CATALOG); + +ok(text.includes("How to read our 402 if you only speak one dialect"), "the new section heading is present"); +ok(text.includes("PAYMENT-REQUIRED"), "names the real x402 header explicitly"); +ok(text.includes("WWW-Authenticate"), "names the MPP header explicitly"); +ok(text.includes("#794"), "cites the real incident (issue #794), not a hypothetical"); +ok(/additive, never a replacement/.test(text), "states the two headers coexist, neither replaces the other"); +// The note must land AFTER the existing MPP dual-stack paragraph, not before +// it — a reader needs the dual-stack context first to make sense of "if you +// only speak one dialect". +const dualStackIdx = text.indexOf("MPP clients are first-class"); +const noteIdx = text.indexOf("How to read our 402"); +ok(dualStackIdx > 0 && noteIdx > dualStackIdx, "the note appears after the MPP dual-stack paragraph it explains"); + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-llms-route-execute-tiers.js b/scripts/test-llms-route-execute-tiers.js new file mode 100644 index 00000000..80bafb18 --- /dev/null +++ b/scripts/test-llms-route-execute-tiers.js @@ -0,0 +1,49 @@ +// Locks the llms.txt route-execute pricing fix (2026-08-16 audit): the +// "Proportional tiers" sentence was a hand-typed list of 3 tiers +// (execute/execute-plus/execute-max) that silently went stale the moment +// route-execute-pro ($3.30, added 2026-08-04) shipped in route-execute.js - +// nobody remembered to also update this unrelated prose file. Fixed by +// deriving the sentence from EXEC_TIERS directly, so a future 5th tier can't +// repeat the same silent omission. +// +// Also locks a bug caught while building that fix: the first draft built the +// URL as `/api/route/${slug}` (slug is "route-execute-plus"), producing the +// doubled path /api/route/route-execute-plus - the REAL route (registered in +// route-execute.js's buildRouteExecuteTool) strips the "route-execute" +// prefix and prepends "execute", giving /api/route/execute-plus. This test +// asserts every tier URL in the sentence is a route that actually appears in +// the live catalog, not just that some URL-shaped string is present. +// +// Offline - calls llmsTxt() directly and cross-checks against the real +// EXEC_TIERS array + a live catalog fixture. +// +// node scripts/test-llms-route-execute-tiers.js +import { llmsTxt } from "../src/seo.js"; +import { EXEC_TIERS } from "../src/tools/route-execute.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const text = llmsTxt("https://agent402.tools", {}); +const sentenceMatch = text.match(/Proportional tiers:.*?fits/); +ok(!!sentenceMatch, "the 'Proportional tiers' sentence is present"); +const sentence = sentenceMatch?.[0] || ""; + +ok(EXEC_TIERS.length >= 4, `sanity: EXEC_TIERS has the tiers we expect to see reflected (got ${EXEC_TIERS.length})`); + +for (const tier of EXEC_TIERS) { + const priceStr = `$${tier.execPriceUsd}`; // e.g. "$3.3" - JS default toString + const priceStr2dp = tier.execPriceUsd.toFixed(2); // "3.30" + const priceMentioned = sentence.includes(priceStr) || sentence.includes(`$${priceStr2dp}`); + ok(priceMentioned, `every EXEC_TIERS price is mentioned in the sentence (${tier.slug}: $${tier.execPriceUsd})`); +} + +// Specifically: route-execute-pro (the tier that went missing) must be named, +// by its REAL route path, not the raw slug. +ok(sentence.includes("execute-pro"), "route-execute-pro's price is present (this is the tier that was missing before the fix)"); +ok(!sentence.includes("/api/route/route-execute"), "no doubled '/api/route/route-execute...' path (the URL-derivation bug caught while building this fix)"); +ok(sentence.includes("/api/route/execute-plus") && sentence.includes("/api/route/execute-max") && sentence.includes("/api/route/execute-pro"), + "every non-base tier links its REAL route path (/api/route/execute-)"); + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-manifest-resources-dedup.js b/scripts/test-manifest-resources-dedup.js new file mode 100644 index 00000000..772d443c --- /dev/null +++ b/scripts/test-manifest-resources-dedup.js @@ -0,0 +1,39 @@ +// Locks the resources[] dedup fix (2026-08-16 audit): serviceManifest() +// mapped Object.keys(catalog) straight to URLs with no dedup - a handful of +// tools (e.g. /api/memory) are registered TWICE in the catalog, once per +// HTTP method (GET read, POST write), so their URL appeared twice in +// /.well-known/x402's resources[] with no way for a consumer to tell why. +// x402scan's discovery format wants a flat URL list, not a method-annotated +// one (openapi.json already carries that), so the fix is to dedupe by URL. +// +// Offline - calls serviceManifest() directly with a fixture catalog that +// deliberately includes a GET+POST pair on the same path, so this is a +// non-vacuous check (a catalog with no duplicate paths at all would pass +// trivially and prove nothing). +import { serviceManifest } from "../src/discovery.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const FIXTURE_CATALOG = { + "GET /api/memory": { slug: "memory-get", name: "Memory read" }, + "POST /api/memory": { slug: "memory-post", name: "Memory write" }, + "POST /api/hash": { slug: "hash", name: "Hash" }, +}; + +const manifest = serviceManifest({ + baseUrl: "https://agent402.tools", network: "base", networks: ["base"], + wallet: "0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0", walletName: "agent402.base.eth", + catalog: FIXTURE_CATALOG, toolCount: 3, powSlugs: new Set(), powDifficulty: 20, prices: {}, +}); + +ok(Array.isArray(manifest.resources), "manifest.resources is an array"); +const memoryUrl = "https://agent402.tools/api/memory"; +const memoryCount = manifest.resources.filter((r) => r === memoryUrl).length; +ok(memoryCount === 1, `/api/memory (registered under 2 methods in the fixture) appears exactly once in resources[] (got ${memoryCount})`); +ok(manifest.resources.includes("https://agent402.tools/api/hash"), "a normal, single-method tool is still present"); +ok(manifest.resources.length === new Set(manifest.resources).size, "no duplicate URL anywhere in resources[] (general dedup, not just the /api/memory special case)"); +ok(manifest.resources.length === 2, `3 catalog entries (2 of which collapse to 1 URL) produce exactly 2 distinct resource URLs (got ${manifest.resources.length})`); + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-market-pages.js b/scripts/test-market-pages.js index 9aa0fb8f..6227ff3a 100644 --- a/scripts/test-market-pages.js +++ b/scripts/test-market-pages.js @@ -6,6 +6,10 @@ import { marketSellers, marketSellersAll, marketPage, marketPanelHtml, CHAIN_PAGES, marketFilterBar } from "../src/market-page.js"; import { sitemapPages, sitemapXml, llmsTxt } from "../src/seo.js"; import { serviceManifest } from "../src/discovery.js"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const filterBarScript = readFileSync(fileURLToPath(new URL("../assets/js/market-filter-bar.js", import.meta.url)), "utf8"); let pass = 0, fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(`ok - ${msg}`); } else { fail++; console.error(`FAIL - ${msg}`); } }; @@ -219,20 +223,22 @@ for (const c of NEW_CHAINS) { // Sort options per the spec: most settled (default), volume, buyers, tools, // plus health (cheap — rows already carry routability). for (const v of ["calls", "usd", "buyers", "tools", "health"]) ok(all.includes(`option value="${v}"`), `filter bar: sort option '${v}' present`); - // The wiring script ships with the bar: consumes data-mfb-sort + - // data-mfb-search over [data-mfb-row] rows, reorders by moving existing - // nodes (appendChild) and toggles style.display — never innerHTML. - ok(all.includes("select[data-mfb-sort]") && all.includes("addEventListener('change'"), "filter bar: script wires the data-mfb-sort select"); - ok(all.includes("input[data-mfb-search]") && all.includes("addEventListener('input'"), "filter bar: script wires the data-mfb-search input"); - ok(all.includes("data-mfb-row") && all.includes("appendChild") && all.includes("style.display"), "filter bar: script sorts/filters [data-mfb-row] rows via appendChild + style.display"); - ok(!all.includes("innerHTML"), "filter bar: script never assigns innerHTML"); - // Execution-order guard — the bar renders ABOVE the roster, so an inline - // script that queries [data-mfb-row] at parse time finds ZERO rows and its - // early return dead-wires Sort/search on every load (the original bug). The - // row lookup must be deferred to DOMContentLoaded: the wrapper must exist - // and textually precede the row query it defers. - const dclIdx = all.indexOf("addEventListener('DOMContentLoaded'"); - ok(dclIdx !== -1 && dclIdx < all.indexOf("querySelectorAll('[data-mfb-row]')"), "filter bar: row lookup is deferred to DOMContentLoaded, not run at parse time"); + // The wiring script ships as an external file (CSP hardening, 2026-08-16): + // consumes data-mfb-sort + data-mfb-search over [data-mfb-row] rows, + // reorders by moving existing nodes (appendChild) and toggles + // style.display — never innerHTML. + ok(all.includes(''), "filter bar: references the external wiring script"); + ok(filterBarScript.includes("select[data-mfb-sort]") && filterBarScript.includes("addEventListener('change'"), "filter bar: script wires the data-mfb-sort select"); + ok(filterBarScript.includes("input[data-mfb-search]") && filterBarScript.includes("addEventListener('input'"), "filter bar: script wires the data-mfb-search input"); + ok(filterBarScript.includes("data-mfb-row") && filterBarScript.includes("appendChild") && filterBarScript.includes("style.display"), "filter bar: script sorts/filters [data-mfb-row] rows via appendChild + style.display"); + ok(!filterBarScript.includes("innerHTML"), "filter bar: script never assigns innerHTML"); + // Execution-order guard — the bar renders ABOVE the roster, so a script + // that queries [data-mfb-row] at parse time finds ZERO rows and its early + // return dead-wires Sort/search on every load (the original bug). The row + // lookup must be deferred to DOMContentLoaded: the wrapper must exist and + // textually precede the row query it defers. + const dclIdx = filterBarScript.indexOf("addEventListener('DOMContentLoaded'"); + ok(dclIdx !== -1 && dclIdx < filterBarScript.indexOf("querySelectorAll('[data-mfb-row]')"), "filter bar: row lookup is deferred to DOMContentLoaded, not run at parse time"); } // Roster rows carry the numeric data-* payload the filter bar script sorts on @@ -247,7 +253,7 @@ for (const c of NEW_CHAINS) { const many = Array.from({ length: 14 }, (_, i) => ({ origin: `https://e${i}.example`, displayName: `E${i}`, homepage: `https://e${i}.example`, local: false, toolCount: i, routable: true, networks: ["eip155:8453"], payToByNetwork: {} })); const chainHtml = marketPage("base", "https://agent402.tools", { snapshot: { sellers: [LOCAL, ...many] }, rail: null, activity: null, wallet: "0x1" }); ok(/]*data-mfb-row data-local="0" data-health="1"[^>]*data-tools="13"/.test(chainHtml), "chain view: compact roster rows carry the same data-* payload"); - ok(chainHtml.includes("select[data-mfb-sort]"), "chain view: filter-bar wiring script is emitted on per-chain pages too"); + ok(chainHtml.includes(''), "chain view: filter-bar wiring script is emitted on per-chain pages too"); } // Roster row cap (speed P0) — the all-chains roster renders at most diff --git a/scripts/test-mcp-find-latest-version.js b/scripts/test-mcp-find-latest-version.js new file mode 100644 index 00000000..4aaaee5c --- /dev/null +++ b/scripts/test-mcp-find-latest-version.js @@ -0,0 +1,59 @@ +// Offline test for scripts/mcp-find-latest-version.js — the helper the +// publish job in deploy.yml pipes the MCP Registry's search response +// through, right before publishing, to capture which version is ABOUT to +// become stale so it can be deprecated right after the new one goes live. +// Drives the real CLI script via child_process + stdin, not a re-import of +// its logic, since the script IS the interface (deploy.yml pipes into it). +// +// node scripts/test-mcp-find-latest-version.js +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "mcp-find-latest-version.js"); + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +function run(input) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [SCRIPT]); + let out = ""; + child.stdout.on("data", (d) => { out += d; }); + child.on("close", () => resolve(out)); + child.stdin.write(input); + child.stdin.end(); + }); +} + +const FIXTURE = JSON.stringify({ + servers: [ + { server: { version: "0.10.0" }, _meta: { "io.modelcontextprotocol.registry/official": { isLatest: false } } }, + { server: { version: "0.11.0" }, _meta: { "io.modelcontextprotocol.registry/official": { isLatest: false } } }, + { server: { version: "0.12.5" }, _meta: { "io.modelcontextprotocol.registry/official": { isLatest: true } } }, + ], +}); + +const NO_LATEST = JSON.stringify({ + servers: [ + { server: { version: "0.10.0" }, _meta: { "io.modelcontextprotocol.registry/official": { isLatest: false } } }, + ], +}); + +const out1 = await run(FIXTURE); +ok(out1 === "0.12.5", `finds the isLatest:true row's version (got "${out1}")`); + +const out2 = await run(NO_LATEST); +ok(out2 === "", `no row is isLatest -> empty output, not a crash (got "${out2}")`); + +const out3 = await run("{}"); +ok(out3 === "", "empty servers list -> empty output"); + +const out4 = await run("not json at all"); +ok(out4 === "", "unparseable input -> empty output, never a throw (the caller treats blank as 'nothing to deprecate')"); + +const out5 = await run(""); +ok(out5 === "", "empty stdin -> empty output"); + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-mpp-shim-mppx-registry.js b/scripts/test-mpp-shim-mppx-registry.js new file mode 100644 index 00000000..5ac92410 --- /dev/null +++ b/scripts/test-mpp-shim-mppx-registry.js @@ -0,0 +1,46 @@ +// Locks the MPP_CHALLENGE_NETWORKS default (2026-08-16 audit) against the +// INSTALLED mppx package rather than trusting a code comment. The claim in +// src/mpp-shim.js is: "a stock mppx client can only natively sign assets in +// its built-in registry (Base + Celo mainnets)" - that used to be an +// unverified assertion. This test reads mppx's real exported Chains/Assets +// and asserts our DEFAULT_CHALLENGE_CHAIN_IDS ({8453, 42220}) is EXACTLY the +// set of mainnet chain ids mppx has a known USDC/USDT asset for - no more, +// no fewer. +// +// This is a drift guard, not a one-time check: a future mppx version bump +// that adds a new mainnet to its registry (their roadmap could add one) SHOULD +// fail this test, prompting a deliberate review of whether +// DEFAULT_CHALLENGE_CHAIN_IDS needs to grow with it - silently missing that +// would mean stock clients on the new chain never get offered an MPP +// challenge even though they could pay one. +// +// node scripts/test-mpp-shim-mppx-registry.js +import { Chains, Assets } from "mppx/evm"; +import { challengeEnabledForChain } from "../src/mpp-shim.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +// Which of mppx's known chain ids are MAINNETS (name doesn't contain "Sepolia"). +const mppxMainnetChainIds = Object.entries(Chains) + .filter(([name]) => !/sepolia/i.test(name)) + .map(([, id]) => id); + +ok(mppxMainnetChainIds.length > 0, "mppx exports at least one mainnet chain id"); +// Calls the REAL production function (no MPP_CHALLENGE_NETWORKS env set, so +// this exercises the actual default, not a value duplicated in this file). +ok(mppxMainnetChainIds.every((id) => challengeEnabledForChain(id)), `challengeEnabledForChain() (real default) is true for every mppx mainnet (${mppxMainnetChainIds.join(",")})`); +// A chain mppx does NOT know natively (Polygon) must stay OFF by default - +// proves the default doesn't just always return true. +ok(!challengeEnabledForChain(137), "challengeEnabledForChain() (real default) is false for a chain mppx has no known asset for (Polygon, 137)"); + +// Assets registry: each of those mainnets must actually have a known USDC +// asset (a mainnet chain id with no asset would be pointless to challenge — +// a stock client has nothing to sign against it even though the chain id exists). +const assetGroupForChainId = (chainId) => Object.values(Assets).find((v) => v && typeof v === "object" && v.USDC?.network === `eip155:${chainId}`); +for (const id of mppxMainnetChainIds) { + ok(!!assetGroupForChainId(id), `mppx has a known USDC asset for mainnet chain ${id}`); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-openapi-header-params.js b/scripts/test-openapi-header-params.js new file mode 100644 index 00000000..526ae5be --- /dev/null +++ b/scripts/test-openapi-header-params.js @@ -0,0 +1,81 @@ +// Locks the openapi.json header-parameter fix (2026-08-16 audit): every +// operation's `parameters` only ever declared query/path params (GET) or +// requestBody (POST) - the two headers every catalog tool actually accepts, +// X-Pow-Solution (free-tier PoW alternative to x402 payment) and +// Idempotency-Key (safe-retry dedup), were real and documented in prose +// (/docs, /llms.txt, quickstart) but invisible to any OpenAPI-driven client +// (Postman, codegen, an agent framework reading the spec instead of prose). +// +// This boots FREE_MODE, fetches /openapi.json, and asserts: +// 1. Idempotency-Key is declared as `in: header` on every operation sampled. +// 2. X-Pow-Solution is declared on PoW-eligible (non-wallet-only) tools. +// 3. X-Pow-Solution is ABSENT on a known wallet-only tool (memory) - PoW +// cannot pay for it, so advertising the header there would mislead a +// codegen client into thinking it has a free-tier option that doesn't +// exist. +// 4. Neither header is ever marked `required` (both are optional). +// +// node scripts/test-openapi-header-params.js +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 3097; +const BASE = `http://localhost:${PORT}`; + +let pass = 0; +const fail = (m) => { console.error("FAIL:", m); try { proc.kill("SIGKILL"); } catch {} process.exit(1); }; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else fail(m); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const proc = spawn(process.execPath, [join(ROOT, "src", "server.js")], { + cwd: ROOT, + env: { ...process.env, FREE_MODE: "true", PORT: String(PORT), X402_SYNC_ON_START: "false" }, + stdio: "ignore", +}); + +try { + for (let i = 0; i < 40; i++) { try { if ((await fetch(`${BASE}/health`)).ok) break; } catch {} await sleep(500); } + + const spec = await (await fetch(`${BASE}/openapi.json`)).json(); + const pricing = await (await fetch(`${BASE}/api/pricing`)).json(); + const catalog = pricing.endpoints || []; + ok(catalog.length > 0, "catalog is non-empty"); + + const opFor = (path) => Object.values(spec.paths[path] || {})[0]; + const paramNamed = (op, name) => (op?.parameters || []).find((p) => p.name === name); + + let idemDeclared = 0, idemOptional = 0; + for (const tool of catalog.slice(0, 30)) { + const op = opFor(tool.path); + const p = paramNamed(op, "Idempotency-Key"); + if (p && p.in === "header") idemDeclared++; + if (p && p.required !== true) idemOptional++; + } + ok(idemDeclared === 30, `Idempotency-Key declared as in:header on first 30 tools (got ${idemDeclared}/30)`); + ok(idemOptional === 30, `Idempotency-Key is never required (got ${idemOptional}/30 optional)`); + + // hash is a well-known PoW-eligible (compute-payable) tool. + const hashOp = opFor("/api/hash"); + const powParam = paramNamed(hashOp, "X-Pow-Solution"); + ok(!!powParam, "X-Pow-Solution declared on a PoW-eligible tool (hash)"); + ok(powParam?.in === "header", "X-Pow-Solution is in:header"); + ok(powParam?.required !== true, "X-Pow-Solution is not required (x402 payment is the alternative)"); + + // memory is wallet-only (payment = identity) - PoW must never be advertised. + const memoryTool = catalog.find((t) => t.slug === "memory-get" || t.path === "/api/memory"); + if (memoryTool) { + const memOp = opFor(memoryTool.path); + ok(!paramNamed(memOp, "X-Pow-Solution"), "X-Pow-Solution is ABSENT on a wallet-only tool (memory)"); + } else { + ok(false, "found a wallet-only tool (memory) in the live catalog to check against"); + } + + proc.kill("SIGKILL"); +} catch (e) { + fail(e?.message || String(e)); +} + +console.log(`\nOK: ${pass} passed`); +process.exit(0); diff --git a/scripts/test-operator-revenue-visibility.js b/scripts/test-operator-revenue-visibility.js new file mode 100644 index 00000000..378d617a --- /dev/null +++ b/scripts/test-operator-revenue-visibility.js @@ -0,0 +1,83 @@ +// Locks two 2026-08-16 audit fixes to the operator dashboard, offline (calls +// getOperatorBreakdown/operatorPage directly, no server boot): +// +// 1. "Ops-cost-vs-revenue visibility for rails with zero external revenue": +// viaUSDCByNetwork only ever carries a key for a rail that has settled +// at least once, so a configured-but-unused rail is invisible by +// omission, not flagged. getOperatorBreakdown() now takes an +// `offeredNetworks` list and returns `railBreakdown`, an explicit row +// per offered rail (settledCalls, zero or not) — cross-referencing what +// we're PAYING to maintain (facilitator config, canary legs, tests) +// against what it has ever earned. +// 2. "Top tools by revenue view (call-volume ≠ revenue proxy)": the +// per-tool table already carried both `calls` and `revenueUsd`, but +// only ever sorted by calls by default — operatorPage() now renders a +// standalone top-5-by-revenue list alongside top-5-by-calls so the two +// leaderboards are visible without clicking a column header. +// +// node scripts/test-operator-revenue-visibility.js +import { getOperatorBreakdown } from "../src/stats.js"; +import { operatorPage } from "../src/operator.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +// --- 1. railBreakdown --- +const dataNoNetworks = getOperatorBreakdown({ prices: {}, walletOnlySet: new Set() }); +ok(Array.isArray(dataNoNetworks.railBreakdown) && dataNoNetworks.railBreakdown.length === 0, + "no offeredNetworks passed → railBreakdown is an empty array, not undefined"); + +// "base"/"polygon"/"solana" are REAL production counter keys and this reads +// the actual shared stats DB — in CI, other test steps in the same job +// legitimately settle real USDC on "base" before this step runs, so +// settledCalls for those cannot be asserted as exactly 0 here (that's a +// process-shared resource, not a fixture). Use one fake network name that +// can never collide with a real CAIP2_NAMES value for the "reports 0, never +// undefined" behavior, and keep "robinhood" only for its name-preservation +// check below (asserted on shape, not count). +const offered = ["base", "polygon", "solana", "robinhood", "__test_fake_network_zzz__"]; +const dataWithNetworks = getOperatorBreakdown({ prices: {}, walletOnlySet: new Set(), offeredNetworks: offered }); +ok(dataWithNetworks.railBreakdown.length === offered.length, + `railBreakdown has one row per offered network (got ${dataWithNetworks.railBreakdown.length}, expected ${offered.length})`); +ok(offered.every((n) => dataWithNetworks.railBreakdown.some((r) => r.network === n)), + "every offered network appears in railBreakdown by name"); +ok(dataWithNetworks.railBreakdown.every((r) => Number.isInteger(r.settledCalls) && r.settledCalls >= 0), + "every row's settledCalls is a non-negative integer, never absent/undefined/NaN"); +const fakeRow = dataWithNetworks.railBreakdown.find((r) => r.network === "__test_fake_network_zzz__"); +ok(fakeRow?.settledCalls === 0, "a network that has never settled anything reports settledCalls:0 exactly"); + +// robinhood settles USDG, booked under the display name "robinhood (USDG)" in +// viaUSDCByNetwork (see CAIP2_NAMES) — the offered-network row must still key +// off the plain "robinhood" name a human/operator recognizes from PAYMENT_NETWORKS. +const robRow = dataWithNetworks.railBreakdown.find((r) => r.network === "robinhood"); +ok(!!robRow && robRow.network === "robinhood", "robinhood keeps its plain PAYMENT_NETWORKS name in railBreakdown, not the display-only \"(USDG)\" suffix"); + +// --- 2. operatorPage renders both leaderboards --- +const sampleData = { + totals: { total: 10, viaUSDC: 8, viaProofOfWork: 2, viaHeartbeat: 0, viaUSDCByNetwork: {}, estimatedRevenueUsd: 3.8, toolsServed: 2 }, + railBreakdown: [{ network: "base", settledCalls: 100 }, { network: "monad", settledCalls: 0 }], + tools: [ + { slug: "route-execute-pro", calls: 1, paid: 1, pow: 0, heartbeat: 0, revenueUsd: 3.3, pricePerCall: 3.3, walletOnly: false }, + { slug: "hash", calls: 500, paid: 500, pow: 0, heartbeat: 0, revenueUsd: 0.5, pricePerCall: 0.001, walletOnly: false }, + ], + recentCalls: [], + processUptimeSeconds: 60, +}; +const html = operatorPage("https://agent402.tools", sampleData); +ok(html.includes("route-execute-pro") && html.includes("$3.3000"), + "the high-price low-volume tool appears with its real revenue in the by-revenue list"); +ok(html.includes("Top by revenue vs top by calls"), "the panel title is present"); +ok(html.includes("Rails offered vs settled"), "the rails panel title is present"); +ok(html.includes("ZERO REVENUE") && html.includes("monad"), + "a zero-settled offered rail (monad) is flagged ZERO REVENUE"); +const baseRow = html.match(/
    base<\/td>.*?<\/tr>/)?.[0] || ""; +ok(baseRow.length > 0 && !baseRow.includes("ZERO REVENUE"), + "a settled rail (base, 100 calls) is NOT flagged ZERO REVENUE"); + +// Empty-state fallbacks must never crash on a cold-boot server (no calls yet). +const coldHtml = operatorPage("https://agent402.tools", { totals: {}, railBreakdown: [], tools: [], recentCalls: [] }); +ok(coldHtml.includes("No data yet."), "cold-boot (no tool calls) renders a 'No data yet.' fallback, not a crash/blank"); +ok(coldHtml.includes("No configured rails."), "cold-boot with no offered networks renders a 'No configured rails.' fallback"); + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-reliability-envelope.js b/scripts/test-reliability-envelope.js index 64020b51..aa738c39 100644 --- a/scripts/test-reliability-envelope.js +++ b/scripts/test-reliability-envelope.js @@ -8,11 +8,11 @@ // This test boots FREE_MODE and locks: // // 1. GET /api/reliability → 200 application/json. -// 2. Envelope: { service, status, asOf, servingSince, uptimeSeconds, +// 2. Envelope: { service, status, asOf, servingSince, processUptimeSeconds, // toolCallsServed, onchain{}, guarantees[], endpoints{}, incidents }. // 3. service === 'Agent402.Tools', status === 'operational' (a 200 by definition // means the node is serving — the field documents that contract). -// 4. asOf parses as ISO; uptimeSeconds and toolCallsServed are numbers. +// 4. asOf parses as ISO; processUptimeSeconds and toolCallsServed are numbers. // 5. onchain has revenueProof URL (or null in FREE_MODE) + a note. // 6. guarantees[] is non-empty AND every entry has `claim` + `verify` — // the verify URL is the trustless half of every claim. @@ -48,7 +48,7 @@ try { const body = await res.json(); // Envelope shape. - for (const k of ["service", "status", "asOf", "servingSince", "uptimeSeconds", "toolCallsServed", "onchain", "guarantees", "endpoints", "incidents"]) { + for (const k of ["service", "status", "asOf", "servingSince", "processUptimeSeconds", "toolCallsServed", "onchain", "guarantees", "endpoints", "incidents"]) { ok(k in body, `envelope key '${k}' present (got: ${Object.keys(body).join(",")})`); } ok(body.service === "Agent402.Tools", `service='Agent402.Tools' (got ${body.service})`); @@ -57,7 +57,11 @@ try { ok(body.status === "operational", `status='operational' (got ${body.status}) — a 200 here documents node liveness`); ok(typeof body.asOf === "string" && !isNaN(Date.parse(body.asOf)), `asOf is parseable ISO (got ${body.asOf})`); ok(typeof body.servingSince === "string", `servingSince is string (got ${typeof body.servingSince})`); - ok(typeof body.uptimeSeconds === "number" && body.uptimeSeconds >= 0, `uptimeSeconds is non-negative number (got ${body.uptimeSeconds})`); + ok(typeof body.processUptimeSeconds === "number" && body.processUptimeSeconds >= 0, `processUptimeSeconds is non-negative number (got ${body.processUptimeSeconds})`); + // Locks the 2026-08-16 rename: the old key name read as a reliability + // claim sitting right next to servingSince (a real ~2-month figure), when + // it actually resets to 0 on every deploy - never let it silently return. + ok(!("uptimeSeconds" in body), "the old 'uptimeSeconds' key name is gone (misreadable as service-availability uptime)"); // toolCallsServed is the structured tally: total + breakdown by payment // path. The breakdown is what's interesting — viaUSDC vs viaProofOfWork // tells a portal which tier dominates traffic. diff --git a/scripts/test-replay-guard-redis.js b/scripts/test-replay-guard-redis.js new file mode 100644 index 00000000..fdabebaa --- /dev/null +++ b/scripts/test-replay-guard-redis.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Exercise the REAL Redis-backed replay guard against a REAL redis server. +// +// REDIS_URL=redis://127.0.0.1:6379 node scripts/test-replay-guard-redis.js +// +// WHY THIS EXISTS (2026-08-16). test-replay-guard.js proves the guard's STATE +// MACHINE (concurrent/sequential replay refused, release-on-failure, TTL, +// eviction) entirely against the per-process fallback - it never sets +// REDIS_URL, so it can never prove the property the Redis backing exists for: +// that TWO SEPARATE createReplayGuard() instances (standing in for two app +// replicas) sharing one Redis actually behave like ONE shared guard. A stub +// client would only prove "the code calls set/get/del", the same shallow +// coverage this repo has been burned by before (see test-redis-integration.js's +// own header) - this drives the real client against a real server instead. +// +// Deliberately does not skip when REDIS_URL is unset: a skipped integration +// test is how the cross-replica gap this guard now closes went uncaught for +// as long as it did. +if (!process.env.REDIS_URL) { + console.error("FAIL: REDIS_URL is not set. This test needs a real redis (CI provides a service container)."); + console.error(" It deliberately does not skip - see the header comment."); + process.exit(1); +} + +const { createReplayGuard, paymentReplayKey } = await import("../src/replay-guard.js"); +const { getSharedRedisClient, __setTestClient } = await import("../src/shared-limit.js"); + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const client = await getSharedRedisClient(); +if (!client) { console.error("FAIL: could not reach redis at", process.env.REDIS_URL); process.exit(1); } + +// Unique key prefix per run so repeated local runs never collide with leftover state. +const runId = `t${Date.now()}${Math.floor(Math.random() * 1e6) || 1}`; +const key = (suffix) => `replay-guard-redis-test:${runId}:${suffix}`; + +// --- 1. Two independent guard instances = two "replicas" ------------------- +// Neither instance's local Map/Set is shared - if the guard were still +// per-process, guardB would see nothing guardA claimed. It must, because +// both share the SAME Redis connection under the hood. +{ + const guardA = createReplayGuard(); + const guardB = createReplayGuard(); + const k = key("cross-replica"); + + const first = await guardA.begin(k); + ok(first === "ok", `replica A claims a fresh nonce (got "${first}")`); + + const second = await guardB.begin(k); + ok(second === "inflight", `replica B sees it as in-flight, NOT "ok" (got "${second}") — proves cross-replica sharing`); + + await guardA.settle(k); + const third = await guardB.begin(k); + ok(third === "consumed", `after A settles, B sees it as consumed (got "${third}") — settlement is visible cross-replica`); +} + +// --- 2. Release is also cross-replica visible ------------------------------- +{ + const guardA = createReplayGuard(); + const guardB = createReplayGuard(); + const k = key("cross-release"); + + await guardA.begin(k); + await guardA.release(k); // e.g. facilitator rejected settlement + const retried = await guardB.begin(k); + ok(retried === "ok", `after A releases, B can claim the SAME nonce fresh (got "${retried}") — release-on-failure works cross-replica`); +} + +// --- 3. The in-flight claim carries a real TTL (self-heals a crashed replica) --- +{ + const k = key("ttl-check"); + const guard = createReplayGuard(); + await guard.begin(k); + const ttl = await client.ttl(`replay:f:${k}`); + ok(ttl > 0 && ttl <= 120, `the in-flight Redis key carries a bounded TTL (got ${ttl}s, expect 0 < ttl <= 120)`); + await guard.release(k); +} + +// --- 4. The consumed marker's TTL matches the guard's configured ttlMs ------- +{ + const k = key("consumed-ttl"); + const guard = createReplayGuard({ ttlMs: 5000 }); // 5s + await guard.begin(k); + await guard.settle(k); + const ttl = await client.ttl(`replay:c:${k}`); + ok(ttl > 0 && ttl <= 5, `the consumed Redis key's TTL matches ttlMs (got ${ttl}s, expect 0 < ttl <= 5)`); +} + +// --- 5. Full paymentReplayKey() -> guard round trip, matching server.js's +// real usage shape (not a synthetic string key). ---------------------------- +{ + const guardA = createReplayGuard(); + const guardB = createReplayGuard(); + const fakeReq = (cred) => ({ header: (n) => (String(n).toLowerCase() === "x-payment" ? cred : undefined) }); + const cred = Buffer.from(JSON.stringify({ + network: "eip155:8453", + payload: { authorization: { nonce: `0x${runId}`, from: "0x" + "1".repeat(40) } }, + })).toString("base64"); + const rk = paymentReplayKey(fakeReq(cred)); + ok(typeof rk === "string" && rk.startsWith("n:"), "paymentReplayKey() extracts a real nonce-scoped key from an x402 credential"); + + ok(await guardA.begin(rk) === "ok", "real payment-credential key: replica A claims it"); + ok(await guardB.begin(rk) === "inflight", "real payment-credential key: replica B sees inflight"); + await guardA.settle(rk); +} + +// --- 6. Fails OPEN to local state when Redis throws (not closed, not a crash) -- +// A rejected paid call over an optimization layer going dark would be the +// wrong direction for THIS guard (see the fail-open rationale in +// replay-guard.js's header) - the real chain-level nonce protection holds +// regardless. Injects a broken client via shared-limit.js's own test seam +// (the same one test-shared-limit.js uses) so this doesn't need a second, +// duplicate connection-mocking mechanism. +{ + const broken = { + get: async () => { throw new Error("simulated redis outage"); }, + set: async () => { throw new Error("simulated redis outage"); }, + del: async () => { throw new Error("simulated redis outage"); }, + }; + __setTestClient(broken); + try { + const g = createReplayGuard(); + const k = "outage-test"; + const r1 = await g.begin(k); + ok(r1 === "ok", `begin() during a redis outage still resolves (not a throw/hang) — falls back to local state (got "${r1}")`); + const r2 = await g.begin(k); + ok(r2 === "inflight", `local-state fallback still enforces the concurrent-replay rule during the outage (got "${r2}")`); + await g.settle(k); + const r3 = await g.begin(k); + ok(r3 === "consumed", `local-state fallback still enforces sequential-replay after settle during the outage (got "${r3}")`); + } finally { + __setTestClient(null); // restore the real client for anything after this + } +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-replay-guard.js b/scripts/test-replay-guard.js index 0c192f23..7daa75df 100644 --- a/scripts/test-replay-guard.js +++ b/scripts/test-replay-guard.js @@ -54,37 +54,38 @@ function evmCred({ network = "eip155:8453", nonce = "0xabc", from = "0x" + "1".r ok(o1 && o1 === o2 && o1.startsWith("c:"), "unparseable credential → stable raw-hash identity"); } -// ---- A2. Guard state machine ------------------------------------------------- +// ---- A2. Guard state machine (local fallback path — no REDIS_URL in this +// process, so every call below exercises the per-process Map/Set) ----------- { const g = createReplayGuard(); const k = "n:test"; - ok(g.begin(k) === "ok", "first use → ok"); - ok(g.begin(k) === "inflight", "concurrent duplicate (still in flight) → inflight"); - g.settle(k); - ok(g.begin(k) === "consumed", "after settle, replay → consumed"); + ok(await g.begin(k) === "ok", "first use → ok"); + ok(await g.begin(k) === "inflight", "concurrent duplicate (still in flight) → inflight"); + await g.settle(k); + ok(await g.begin(k) === "consumed", "after settle, replay → consumed"); ok(g._state().consumed === 1 && g._state().inFlight === 0, "state: 1 consumed, 0 in flight"); } { const g = createReplayGuard(); const k = "n:retry"; - ok(g.begin(k) === "ok", "first attempt → ok"); - g.release(k); // gated call was NOT granted (e.g. settle failed) - ok(g.begin(k) === "ok", "release-on-failure → same authorization may retry"); + ok(await g.begin(k) === "ok", "first attempt → ok"); + await g.release(k); // gated call was NOT granted (e.g. settle failed) + ok(await g.begin(k) === "ok", "release-on-failure → same authorization may retry"); ok(g._state().inFlight === 1 && g._state().consumed === 0, "retry is in flight, nothing consumed yet"); } { // TTL expiry: a consumed nonce older than ttl is pruned, freeing the key. const g = createReplayGuard({ ttlMs: 1000 }); const k = "n:ttl"; - g.begin(k, 0); - g.settle(k, 0); - ok(g.begin(k, 500) === "consumed", "within TTL → still consumed"); - ok(g.begin(k, 5000) === "ok", "past TTL → pruned, key reusable (safe: on-chain nonce still dead)"); + await g.begin(k, 0); + await g.settle(k, 0); + ok(await g.begin(k, 500) === "consumed", "within TTL → still consumed"); + ok(await g.begin(k, 5000) === "ok", "past TTL → pruned, key reusable (safe: on-chain nonce still dead)"); } { // FIFO eviction keeps memory bounded; eviction is always safe. const g = createReplayGuard({ maxEntries: 3 }); - for (let i = 0; i < 5; i++) { g.begin(`k${i}`); g.settle(`k${i}`); } + for (let i = 0; i < 5; i++) { await g.begin(`k${i}`); await g.settle(`k${i}`); } ok(g._state().consumed <= 3, `consumed capped at maxEntries (got ${g._state().consumed})`); } @@ -95,16 +96,16 @@ function buildApp({ grant }) { const app = express(); const guard = createReplayGuard(); let handlerCalls = 0; - app.use((req, res, next) => { + app.use(async (req, res, next) => { const key = paymentReplayKey(req); if (key) { - const verdict = guard.begin(key); + const verdict = await guard.begin(key); if (verdict !== "ok") { res.setHeader("X-Payment-Replay", verdict); return res.status(409).json({ error: "replay", reason: verdict }); } let resolved = false; - const fin = () => { if (resolved) return; resolved = true; if (res.statusCode === 200) guard.settle(key); else guard.release(key); }; + const fin = () => { if (resolved) return; resolved = true; if (res.statusCode === 200) guard.settle(key).catch(() => {}); else guard.release(key).catch(() => {}); }; res.on("finish", fin); res.on("close", fin); } diff --git a/scripts/test-reveal-coverage.js b/scripts/test-reveal-coverage.js index d35ddfae..f756dcf3 100644 --- a/scripts/test-reveal-coverage.js +++ b/scripts/test-reveal-coverage.js @@ -30,7 +30,10 @@ let pass = 0, fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(`ok - ${msg}`); } else { fail++; console.error(`FAIL - ${msg}`); } }; // --- the shared script's selector must stay broad, not opt-in-only -------- -const chromeSrc = readFileSync(join(ROOT, "src", "ledger-chrome.js"), "utf8"); +// Lives in assets/js/site-chrome.js (external file, CSP hardening, +// 2026-08-16) - was inline in src/ledger-chrome.js's shared script +// before that. +const chromeSrc = readFileSync(join(ROOT, "assets", "js", "site-chrome.js"), "utf8"); ok(/querySelectorAll\('header,section,\[data-reveal\]'\)/.test(chromeSrc), "shared reveal-on-scroll observer selects header/section site-wide, not just [data-reveal] opt-ins"); @@ -39,19 +42,28 @@ ok(/querySelectorAll\('header,section,\[data-reveal\]'\)/.test(chromeSrc), // a partial regression (one section quietly reverting to a
    , not the // whole page) still fails instead of hiding behind the sections that // weren't touched. Bumping a number here is expected and fine when a page -// legitimately gains/loses a section; a silent drop is what this catches. */ +// legitimately gains/loses a section; a silent drop is what this catches. +// +// Script tags are stripped before counting: this reveal-on-scroll script's +// OWN explanatory comments used to mention the literal substrings +// "
    " and "
    script), which the naive regex below +// counted as real elements - inflating every single page's count by +// exactly 2 and hiding behind it since the floors were calibrated against +// that inflated number. Externalizing the script (CSP hardening, +// 2026-08-16) correctly dropped those two per-page phantom matches, and the +// floors below are recalibrated against the TRUE element count so this +// test measures the same "real elements" it always claimed to. */ const MIN_SECTIONS = { - "/": 10, "/base": 5, "/marketplace": 8, "/pricing": 7, "/leaderboard": 6, "/skills": 7, "/tools": 5, "/what-is-x402": 11, "/sell": 10, - // Extended 2026-08-15: these 18 pages were
    -only (zero real sections, - // zero reveal-on-scroll effect) until this pass gave each one real - //
    /
    markup for the shared observer to reach. - "/docs": 7, "/status": 5, "/faq": 3, "/revenue": 5, "/playground": 3, "/badges": 4, "/compare": 7, "/community": 7, - "/changelog": 3, "/blog": 3, "/transparency": 3, "/privacy": 3, "/terms": 3, "/contact": 4, "/analytics": 4, - "/workflows": 4, "/quickstart": 4, "/guides": 3, + "/": 9, "/base": 4, "/marketplace": 7, "/pricing": 6, "/leaderboard": 5, "/skills": 6, "/tools": 4, "/what-is-x402": 10, "/sell": 9, + "/docs": 6, "/status": 4, "/faq": 2, "/revenue": 4, "/playground": 2, "/badges": 3, "/compare": 6, "/community": 6, + "/changelog": 2, "/blog": 2, "/transparency": 2, "/privacy": 2, "/terms": 2, "/contact": 3, "/analytics": 3, + "/workflows": 3, "/quickstart": 3, "/guides": 2, }; for (const [path, min] of Object.entries(MIN_SECTIONS)) { const html = await (await fetch(`${BASE}${path}`)).text(); - const realSectionCount = (html.match(/]/g) || []).length + (html.match(/]/g) || []).length; + const stripped = html.replace(//gi, ""); + const realSectionCount = (stripped.match(/]/g) || []).length + (stripped.match(/]/g) || []).length; ok(realSectionCount >= min, `${path}: has at least ${min} real
    /
    elements to reveal (got ${realSectionCount})`); } diff --git a/scripts/test-reveal-eager.js b/scripts/test-reveal-eager.js new file mode 100644 index 00000000..c505be80 --- /dev/null +++ b/scripts/test-reveal-eager.js @@ -0,0 +1,73 @@ +// Locks the data-reveal-eager opt-in (2026-08-16 audit): the hero-flash fix +// only ever exempted the FIRST matched header/section from the reveal-on- +// scroll hide-then-observe cycle. Some pages have a SECOND section that's +// also reliably above the fold (found on /pricing - the tier cards sit +// directly under a short hero) and got the same flash the hero fix already +// solved for index 0. Rather than guessing "how many leading sections are +// above the fold" generically - unsafe without a getBoundingClientRect read +// before webfonts/map settle, and wrong on every page where the second +// section really IS below the fold - this is an explicit, opt-in +// [data-reveal-eager] marker a page sets on a specific section it knows is +// always visible on load. +// +// Verified via a real browser (Playwright), not static source inspection - +// the whole point is client-side class-application behavior at +// DOMContentLoaded time. +// +// Requires a booted server (same TARGET_URL convention as other page tests): +// FREE_MODE=true PORT=3000 node src/server.js +// TARGET_URL=http://localhost:3000 node scripts/test-reveal-eager.js +import { chromium } from "playwright"; + +const BASE = process.env.TARGET_URL || "http://localhost:3000"; +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage(); + + // /pricing's second section is marked data-reveal-eager - must never get + // the hiding class at all, must render fully visible immediately. + await page.goto(`${BASE}/pricing`, { waitUntil: "domcontentloaded" }); + const eager = await page.evaluate(() => { + const els = document.querySelectorAll("header,section,[data-reveal]"); + const el = els[1]; + return el ? { present: true, hasEagerAttr: el.hasAttribute("data-reveal-eager"), hasRevealClass: el.classList.contains("ml-reveal"), opacity: getComputedStyle(el).opacity } : { present: false }; + }); + ok(eager.present, "/pricing has a second header/section element to check"); + ok(eager.hasEagerAttr, "/pricing's second section actually carries the data-reveal-eager marker (test isn't checking the wrong element)"); + ok(!eager.hasRevealClass, "the eager-marked section never gets the ml-reveal hiding class"); + ok(eager.opacity === "1", `the eager-marked section renders at full opacity immediately (got ${eager.opacity})`); + + // A page whose second section is NOT marked eager must be completely + // unaffected - still gets the hiding class, still fades in on scroll. + // This is the regression this fix could most easily cause: exempting too + // much and quietly breaking the reveal effect for genuinely below-fold + // content everywhere else. + await page.goto(`${BASE}/marketplace`, { waitUntil: "domcontentloaded" }); + const notMarked = await page.evaluate(() => { + const els = document.querySelectorAll("header,section,[data-reveal]"); + const el = els[1]; + return el ? { present: true, hasEagerAttr: el.hasAttribute("data-reveal-eager"), hasRevealClass: el.classList.contains("ml-reveal") } : { present: false }; + }); + ok(notMarked.present, "/marketplace has a second header/section element to check"); + ok(!notMarked.hasEagerAttr, "/marketplace's second section is genuinely unmarked (test isn't accidentally checking a marked page)"); + ok(notMarked.hasRevealClass, "an UNMARKED second section still gets the hiding class - the opt-in doesn't leak to pages that never asked for it"); + + // The hero (first match) must still be exempt exactly as before on both + // pages - this fix must not have disturbed the original hero-flash fix. + for (const path of ["/pricing", "/marketplace"]) { + await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" }); + const hero = await page.evaluate(() => { + const el = document.querySelectorAll("header,section,[data-reveal]")[0]; + return el ? el.classList.contains("ml-reveal") : null; + }); + ok(hero === false, `${path}: the hero (first match) still never gets the hiding class`); + } +} finally { + await browser.close(); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-reveal-no-hero-flash.js b/scripts/test-reveal-no-hero-flash.js index d605282f..e3acd10f 100644 --- a/scripts/test-reveal-no-hero-flash.js +++ b/scripts/test-reveal-no-hero-flash.js @@ -10,14 +10,16 @@ // script in src/ledger-chrome.js for the full history and the deliberate // choice to key off DOM order rather than a getBoundingClientRect check. // -// Offline - reads the source directly, no server needed. +// Offline - reads the source directly, no server needed. CSP hardening +// (2026-08-16) moved this script from an inline ledgerShell '; + if (!html.includes(tag)) throw new Error("revenueChartSection() no longer references /js/revenue-chart.js - did the src path change?"); + return html.replace(tag, ``); +} + const REV_DAYS = [ // Base day carries a SOR (spending-wallet-settled) subset: $0.50 of the $1.50. { day: "2026-06-20", chain: "base", extUsd: 1.5, intUsd: 0.2, extTx: 3, intTx: 1, extMppUsd: 0, intMppUsd: 0, extMppTx: 0, intMppTx: 0, extSorUsd: 0.5, extSorTx: 1, intSorUsd: 0, intSorTx: 0 }, @@ -43,7 +58,7 @@ const check = (name, fn) => { }; async function boot({ freeFails = false } = {}) { - const html = `${revenueChartSection()}`; + const html = `${inlineRevenueChartScript(revenueChartSection())}`; // fetch must exist BEFORE the inline script runs, so the chart's own IIFE is // the one under test — re-evaluating it afterwards would double every // listener and make the segmented controls toggle twice per click. diff --git a/scripts/test-sell-page.js b/scripts/test-sell-page.js index 69f671b4..886f27ab 100644 --- a/scripts/test-sell-page.js +++ b/scripts/test-sell-page.js @@ -3,6 +3,7 @@ // new design surfaces no free aggregate numbers on this page at all, only // static "what it costs you" copy and a lane-level teaser for the paid // /api/bestsellers + /api/demand-radar reads). +import { readFileSync } from "node:fs"; import { sellPage } from "../src/sell.js"; let pass = 0, fail = 0; @@ -31,8 +32,12 @@ ok(!/\b\d{1,3}(,\d{3})*\s*(purchases|sales|buyers)\b/i.test(html.replace(/\/tool // --- register form: same id/posture as market-page.js --------------------- ok(html.includes('id="list-api"') && html.includes('id="reg-origin"') && html.includes('id="reg-go"') && html.includes('id="reg-out"'), "register form present with market-page.js's ids"); -ok(html.includes("/api/index/register"), "register form posts to /api/index/register"); -ok(html.includes("out.textContent") && !html.includes("innerHTML"), "register-result rendering is textContent-only, never innerHTML"); +ok(html.includes(''), "List your API form renders"); // Activity section: cards + bars + honesty captions const buckets = Array.from({ length: 30 }, (_, i) => ({ date: "2026-06-" + String(11 + (i % 20)).padStart(2, "0"), tx: i === 29 ? 5 : 0, usd: i === 29 ? 0.05 : 0, buyers: i === 29 ? 2 : 0 })); diff --git a/scripts/test-theme.js b/scripts/test-theme.js index ed5fac6e..60afbf0d 100644 --- a/scripts/test-theme.js +++ b/scripts/test-theme.js @@ -87,9 +87,22 @@ ok((html.match(/]/gi) || []).length === (html.match(/<\/script>/gi) | const headOnly = html.slice(html.indexOf(""), html.indexOf("")); ok(!/\n\s*function\s+\w+\s*\(/.test(headOnly), "no bare function declaration sitting outside a `; + `; return ledgerShell({ title, diff --git a/src/api-explorer.js b/src/api-explorer.js index c95afeec..afec0cb3 100644 --- a/src/api-explorer.js +++ b/src/api-explorer.js @@ -43,8 +43,7 @@ export function apiExplorerPage(baseUrl) { `; const body = ` - -
    +

    HomeDocs › API Explorer

    $ GET /docs/api/explorer

    API Explorer.

    @@ -58,134 +57,7 @@ export function apiExplorerPage(baseUrl) {
    ${ledgerFooterCompact()} -`; +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/docs", extraCss, body }); } diff --git a/src/badges.js b/src/badges.js index b17e1689..c0cfecb0 100644 --- a/src/badges.js +++ b/src/badges.js @@ -169,21 +169,7 @@ ${badgeSections}
    ${ledgerFooterCompact()} -`; +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/badges", extraCss, body }); } diff --git a/src/compare.js b/src/compare.js index 1664cf8e..8b630903 100644 --- a/src/compare.js +++ b/src/compare.js @@ -21,7 +21,8 @@ export function comparePage(baseUrl) { .cmp-section{margin-bottom:48px} .cmp-section h2{font-family:var(--font-body);font-weight:800;font-size:34px;line-height:1;letter-spacing:-.02em;margin:0 0 10px;color:var(--ink)} .cmp-section p.cmp-desc{color:var(--muted);font-size:15px;line-height:1.55;margin:0 0 20px;max-width:760px} -.cmp-table{width:100%;border-collapse:collapse;background:var(--card);border:1.5px solid var(--ink);overflow:hidden;font-size:14px} +.cmp-scroll{overflow-x:auto} +.cmp-table{width:100%;min-width:520px;border-collapse:collapse;background:var(--card);border:1.5px solid var(--ink);overflow:hidden;font-size:14px} .cmp-table th,.cmp-table td{padding:14px 18px;text-align:left;border-bottom:1px solid var(--hairline)} .cmp-table thead th{background:var(--surface);color:var(--on-dark);font-family:var(--font-mono);font-weight:700;font-size:13px;text-transform:uppercase;letter-spacing:.04em} .cmp-table thead th:first-child{color:var(--dk-muted2);font-weight:500;text-transform:none;letter-spacing:normal} @@ -53,7 +54,7 @@ export function comparePage(baseUrl) {

    Agent402 vs. building your own tool server

    Standing up a custom tool server means writing handlers, managing uptime, handling payments, and keeping up with upstream API changes. Agent402 ships all of that out of the box.

    - +
    @@ -63,7 +64,7 @@ export function comparePage(baseUrl) { -
    DimensionAgent402Build your own
    Setup time One npm install or HTTP callWeeks of engineering
    MCP support Native MCP endpointImplement yourself
    Cost Pay per call, free tier via PoWServer + engineer time
    +
    @@ -71,7 +72,7 @@ export function comparePage(baseUrl) {

    Agent402 vs. raw API calls

    Wiring an agent directly to upstream APIs means juggling API keys, reading per-provider docs, and building error handling for each service individually.

    - +
    @@ -80,7 +81,7 @@ export function comparePage(baseUrl) { -
    DimensionAgent402Raw API calls
    Authentication x402 protocol - one wallet, all toolsSeparate API key per service
    Payment Per-call, pay only for what you useMonthly subscriptions per provider
    Retries Idempotency built inBuild retry logic yourself
    +
    @@ -88,7 +89,7 @@ export function comparePage(baseUrl) {

    Agent402 vs. hosted AI tool platforms

    Hosted platforms can get you started fast, but they typically lock you in with proprietary APIs, monthly fees, and opaque LLM-dependent tool logic.

    - +
    @@ -97,7 +98,7 @@ export function comparePage(baseUrl) { -
    DimensionAgent402Hosted platforms
    Open source Fully open source Proprietary
    Lock-in None - standard protocolsVendor lock-in
    Deterministic Every tool is deterministicLLM-dependent, non-reproducible
    +
    @@ -105,7 +106,7 @@ export function comparePage(baseUrl) {

    agent402-tollbooth vs. platform monetization gateways

    The other side of the protocol: charging AI crawlers and agents for your content. Cloudflare's announced Monetization Gateway (waitlist at the time of writing) brings x402 charging to sites behind Cloudflare - strong validation that pay-per-request is the business model of the agentic web. agent402-tollbooth is the open-source take on the same idea: self-hostable, in front of any origin.

    - +
    @@ -116,7 +117,7 @@ export function comparePage(baseUrl) { -
    Dimensionagent402-tollboothPlatform gateways (e.g. Cloudflare)
    Availability Live today - npm i agent402-tollboothWaitlist / beta
    Charge rules Your code - modes (bots/all/strict), adaptive PoW, per-path pricingPlatform rules API
    Analytics Built-in dashboard + stats endpoint, self-hostedPlatform dashboard
    +
    diff --git a/src/contact.js b/src/contact.js index c74cbea9..c81c1887 100644 --- a/src/contact.js +++ b/src/contact.js @@ -45,7 +45,8 @@ export function contactPage(baseUrl) { .ct-field textarea{min-height:120px;resize:vertical} .ct-submit{background:var(--surface);color:var(--on-dark);font-family:var(--font-mono);font-weight:700;font-size:14px;border:none;padding:12px 24px;cursor:pointer} .ct-submit:hover{opacity:.85} -.ct-sent{display:none;background:var(--card);border:1.5px solid var(--green);padding:18px 22px;margin-bottom:44px;color:var(--ink);font-size:15px} +.ct-fallback{color:var(--faint);font-size:12.5px;margin:14px 0 0;line-height:1.5} +.ct-fallback a{color:var(--muted)} `; const body = ` @@ -82,11 +83,9 @@ export function contactPage(baseUrl) {
    -
    Thanks for reaching out! I'll get back to you soon.
    -

    Send a message.

    -

    I'll get back to you as soon as I can.

    +

    Opens in your email app, pre-filled and ready to send.

    @@ -102,6 +101,7 @@ export function contactPage(baseUrl) {
    +

    No email app configured on this device? Copy the address instead: mike@agent402.tools

    diff --git a/src/contribute.js b/src/contribute.js index 1d26038f..8e4ca22e 100644 --- a/src/contribute.js +++ b/src/contribute.js @@ -282,21 +282,7 @@ TARGET_URL=http://localhost:3000 node scripts/test-mcp-all.js
    ${ledgerFooterCompact()} -`; +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/contribute", extraCss, body }); } diff --git a/src/discovery.js b/src/discovery.js index 99439f9f..3bdb6a44 100644 --- a/src/discovery.js +++ b/src/discovery.js @@ -63,7 +63,13 @@ export function serviceManifest({ baseUrl, network, networks, wallet, walletName // /.well-known/x402 fan-out wants `version: 1` + a `resources` URL array. // Additive — everything below remains the richer agent-facing manifest. version: 1, - resources: Object.keys(catalog).map((route) => `${baseUrl}${route.split(" ")[1] || route}`), + // Dedupe by URL, not by catalog key: a handful of tools (e.g. /api/memory) + // are registered twice in the catalog, once per HTTP method (GET read, + // POST write) - x402scan's discovery format wants a flat resource-URL + // list, not a method-annotated one (openapi.json already carries that), + // so the honest fix here is "list the URL once" rather than emitting the + // same address twice with no way for a consumer to tell why. + resources: [...new Set(Object.keys(catalog).map((route) => `${baseUrl}${route.split(" ")[1] || route}`))], about: `${REPO}#agent402-in-the-x402-ecosystem`, name: "Agent402.Tools", summary: @@ -251,7 +257,7 @@ export function reliabilityReport({ baseUrl, network, wallet, stats }) { status: "operational", asOf: new Date().toISOString(), servingSince: stats.servingSince, - uptimeSeconds: stats.uptimeSeconds, + processUptimeSeconds: stats.processUptimeSeconds, toolCallsServed: stats.toolCallsServed, onchain: { revenueProof: wallet ? `${explorer}/address/${wallet}#tokentxns` : null, diff --git a/src/docs.js b/src/docs.js index fc004f5e..1b6775e7 100644 --- a/src/docs.js +++ b/src/docs.js @@ -207,7 +207,7 @@ export function docsLayoutHtml(currentSlug, mainHtml) { return `
    -
    ${mainHtml}
    +
    ${mainHtml}
    `; } @@ -292,47 +292,11 @@ export const DOCS_LAYOUT_CSS = ` } @media (max-width:640px) { .ml-docs-pn { grid-template-columns:1fr; } }`; -// Mobile sidebar toggle: below 900px the sidebar is display:none by default -// (CSS), with no other way to reach it - this opens/closes it in place -// above the main content. Desktop never sees the button (CSS hides it at -// the same breakpoint the sidebar itself becomes permanently visible), so -// there's nothing to wire up there. -const DOCS_MOBILE_TOGGLE_SCRIPT = `(function(){ - var toggle=document.getElementById('ml-docs-mobile-toggle'); - var side=document.getElementById('ml-docs-side'); - if(!toggle||!side)return; - toggle.addEventListener('click',function(){ - var open=side.classList.toggle('ml-docs-side-open'); - toggle.setAttribute('aria-expanded',open?'true':'false'); - }); -})();`; - -// Sidebar filter: hides non-matching
  • items as you type. No network, no -// fetch - the sidebar is already fully rendered, this just toggles -// visibility client-side. Group headers with zero visible items also hide, -// so a filter doesn't leave an empty "Reference" label floating above -// nothing. -export const DOCS_SEARCH_SCRIPT = ``; +// Mobile sidebar toggle (below 900px the sidebar is display:none by default, +// with no other way to reach it) + sidebar filter (hides non-matching
  • +// items as you type, group headers with zero visible items also hide) — +// externalized to assets/js/docs-sidebar.js (CSP hardening, 2026-08-16). +export const DOCS_SEARCH_SCRIPT = ``; function shell(baseUrl, title, description, path, body, currentSlug) { const extraCss = DOCS_LAYOUT_CSS; diff --git a/src/ledger-catalog.js b/src/ledger-catalog.js index 7ca0b309..cb62f171 100644 --- a/src/ledger-catalog.js +++ b/src/ledger-catalog.js @@ -62,6 +62,8 @@ export function ledgerCatalogPage(baseUrl, catalog, skillPacks) { .cat-scroll table{min-width:780px} table{border-collapse:collapse;width:100%} @media (max-width:900px){.cat-2col{grid-template-columns:minmax(0,1fr)!important}} +.cat-search-wrap{border:1.5px solid var(--ink)} +.cat-search-wrap:focus-within{border-color:var(--accent)} `; const tabsHtml = TABS.map(([label, href]) => @@ -87,7 +89,7 @@ table{border-collapse:collapse;width:100%}

    Our tools

    ${fmtNum(count)} deterministic tools an agent can call and pay for per request. Around ${fmtNum(freeCount)} of them run free on proof-of-work. This is our own catalog - for every tool in the index, ours and other sellers', see all indexed tools.

    -
    +
    @@ -166,33 +168,7 @@ table{border-collapse:collapse;width:100%} ${ledgerFooterCompact()} -`; +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/tools", jsonLd: [orgLd, breadcrumbLd, pageLd, listLd, appLd], extraCss, body }); } diff --git a/src/ledger-chrome.js b/src/ledger-chrome.js index 24d1d5a3..f947a71b 100644 --- a/src/ledger-chrome.js +++ b/src/ledger-chrome.js @@ -9,6 +9,25 @@ import { RAILS, RAILS_AMP, RAILS_OS } from "./rails.js"; export const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +// Safely embeds a JSON-serializable value into a page for a same-origin +// external script to read (the CSP-hardening replacement for baking +// per-request server data directly into inline JS text - see the 2026-08-16 +// migration). MUST escape every "<" in the JSON output: JSON.stringify never +// escapes it, so a string field containing the literal text "" +// (however that got in there - a crawled seller's tool description is +// exactly this kind of untrusted field) would prematurely close the tag and +// let whatever follows execute as HTML/script, a well-known JSON-in-HTML +// pitfall. < is valid inside a JSON string and round-trips through +// JSON.parse to the same "<" character, so this is lossless, not just safe. +// id must be a simple token (enforced) - it becomes a literal attribute +// value, never interpolated from anything that could carry a quote. +const SAFE_ISLAND_ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +export function jsonScriptTag(id, value) { + if (!SAFE_ISLAND_ID.test(id)) throw new Error(`jsonScriptTag: unsafe id "${id}"`); + const json = JSON.stringify(value).replace(/${json}`; +} + // Official GitHub mark (the "Octocat" silhouette) - fill:currentColor so it // tracks the surrounding text color (var(--muted), hover states) exactly // like the plain-text "github" link it replaces used to, with no separate @@ -73,7 +92,14 @@ html { overflow-x: clip; } --ink: #ECECEA; --ink-panel: #171719; --muted: #9E9E98; - --faint: #6C6C68; + /* Was #6C6C68 (3.15-3.66:1 against paper/card/card-zebra/footer-bg - + fails WCAG AA's 4.5:1 for normal text) - --faint is used at 10-13px in + shared nav/footer chrome that reaches every page. Raised to clear + 4.5:1 with margin (4.86-5.64:1) against every dark surface it actually + appears on, keeping the original warm tint (R=G, B slightly lower) and + staying visually distinct from --muted (found in an internal audit, + 2026-08-16). */ + --faint: #8B8B87; --hairline: #2A2A30; --dash: #35353B; --dark-border: #262626; @@ -530,7 +556,7 @@ function nav(activePath) { ${GITHUB_ICON_SVG} ${activePath === "" || activePath === "/sell" ? "" : `LIST YOUR API →`} - @@ -666,7 +692,7 @@ export function setOgImageVersion(v) { ogImageVersion = String(v || ""); } function posthogSnippet(baseUrl) { const key = process.env.POSTHOG_API_KEY || ""; if (!key) return ""; - const cfg = JSON.stringify({ + const cfg = { api_host: `${baseUrl}/e`, ui_host: "https://us.posthog.com", persistence: "sessionStorage", @@ -676,8 +702,12 @@ function posthogSnippet(baseUrl) { capture_performance: { web_vitals: true, network_timing: false }, disable_session_recording: true, disable_surveys: true, - }); - return ``; + }; + // The vendor loader itself is 100% static (assets/js/posthog-loader.js); + // only the API key and per-deployment config vary, so they ride as a JSON + // island the loader reads at runtime instead of being templated into JS + // text (CSP hardening, 2026-08-16). + return jsonScriptTag("posthog-config", { key, cfg }) + ''; } export function ledgerShell({ title, description, canonical, baseUrl, activePath = "", ogImage, jsonLd, extraCss = "", body }) { const og = ogImage || (baseUrl + "/card.png" + (ogImageVersion ? `?v=${ogImageVersion}` : "")); @@ -702,49 +732,7 @@ export function ledgerShell({ title, description, canonical, baseUrl, activePath - + ${esc(title)} diff --git a/src/ledger-home.js b/src/ledger-home.js index f5da6892..bc5a573e 100644 --- a/src/ledger-home.js +++ b/src/ledger-home.js @@ -104,6 +104,8 @@ export function ledgerHomePage(baseUrl, catalog, stats, leaderboardSnapshot, ski .hm-2col { display: grid; grid-template-columns: 1fr 1fr; } @media (max-width: 900px) { .hm-2col, .hm-3col, .hm-hero { grid-template-columns: minmax(0,1fr) !important; } } @media (max-width: 480px) { .hm-reg-row { flex-direction: column !important; } .hm-reg-row button { width: 100%; } } +#hm-demo-in { border: 1.5px solid var(--hairline); } +#hm-demo-in:focus { border-color: var(--accent); } `; const railLinksHtml = CHAIN_ORDER.map(([slug, name]) => @@ -166,7 +168,7 @@ export function ledgerHomePage(baseUrl, catalog, stats, leaderboardSnapshot, ski
    -
    ${viaUsdc ? fmtNum(viaUsdc) : ""}
    +
    ${viaUsdc ? fmtNum(viaUsdc) : ""}
    Listening for on-chain payments… @@ -268,7 +270,7 @@ export function ledgerHomePage(baseUrl, catalog, stats, leaderboardSnapshot, ski
    - +
    1. ·Request a challenge
      signed, single-use, scoped to one tool
    2. @@ -428,257 +430,7 @@ curl -X POST /api/hash \ ${ledgerFooterFull()} -`; +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "", jsonLd: [orgLd, websiteLd, appLd, datasetLd, surfacesLd, faqLd], extraCss, body }); } diff --git a/src/ledger-pricing.js b/src/ledger-pricing.js index 8b9e353a..92db93c6 100644 --- a/src/ledger-pricing.js +++ b/src/ledger-pricing.js @@ -74,7 +74,7 @@ export function ledgerPricingPage(baseUrl, catalog) { -
      +
      diff --git a/src/market-page.js b/src/market-page.js index db65cb56..ef4e992d 100644 --- a/src/market-page.js +++ b/src/market-page.js @@ -35,7 +35,9 @@ const ROSTER_CSS = ` .mlr-badge{background:var(--accent);color:#fff;font-family:var(--font-mono);font-size:10px;font-weight:700;padding:1px 5px} .mlr-mpp{border:1px solid var(--green);color:var(--green);font-family:var(--font-mono);font-size:10px;font-weight:700;padding:0 4px;margin-left:4px} .ml-chain-h1-wrap{min-height:80px} -@media (max-width: 900px) { .ml-chain-h1-wrap{min-height:120px} }`; +@media (max-width: 900px) { .ml-chain-h1-wrap{min-height:120px} } +.mkt-search-wrap{border:1.5px solid var(--ink)} +.mkt-search-wrap:focus-within{border-color:var(--accent)}`; const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); // Crawled manifests are third-party input: only http(s) may become an href. @@ -371,26 +373,7 @@ export function marketFilterBar(chainKey, _baseUrl) {
      - `; + `; } function categoryGroups(tools, { maxCategories = 12, maxPerCategory = 6 } = {}) { @@ -773,24 +756,14 @@ export function marketPage(chainKey, baseUrl, opts = {}) { const formHtml = `
      List your API
      +
      -
      Free, no account - we probe your origin's x402 surface and list you if it answers. Ranking is health-based.
      +
      Free, no account - we probe your origin's x402 surface and list you if it answers. Ranking is health-based.
      - `; + `; const canary = canaryManifestStatus(rail); const manifestRow = (label, value) => `
      ${label}${value}
      `; @@ -830,7 +803,7 @@ export function marketPage(chainKey, baseUrl, opts = {}) {

      ${subheadHtml}

      START HERE · BUYER PATH
      -
      +
      @@ -880,7 +853,7 @@ export function marketPage(chainKey, baseUrl, opts = {}) {
      ${headerHtml}
      -
      ${marketPanelHtml(chainKey, { snapshot, activity, selectedSeller, leaderboardSnap })}
      +
      ${marketPanelHtml(chainKey, { snapshot, activity, selectedSeller, leaderboardSnap })}
      ${rosterHtml}
      @@ -895,33 +868,7 @@ export function marketPage(chainKey, baseUrl, opts = {}) {

      machine-readable: /api/route?network=${esc(C.networkParam)} · /.well-known/x402 · /openapi.json · /api/reliability

      - + ${ledgerFooterCompact()}`; return ledgerShell({ @@ -1166,7 +1113,7 @@ function marketPageAll(baseUrl, { snapshot, leaderboardSnap, economySnap, all =

      The open index of paid APIs for agentic commerce - ${baseSellerCount.toLocaleString("en-US")} sellers on Base alone, theirs as well as ours, with what they charge and what they have actually settled.

      START HERE · BUYER PATH
      -
      + diff --git a/src/mpp-shim.js b/src/mpp-shim.js index 34b42f34..ca1243b0 100644 --- a/src/mpp-shim.js +++ b/src/mpp-shim.js @@ -51,10 +51,22 @@ const STABLECOIN_DECIMALS = 6; // crawler-swept response) — so the default covers exactly what stock clients // can pay. MPP_CHALLENGE_NETWORKS overrides: "all" (every eip155 accepts // entry) or a CSV of chain ids. Call-time read, like other rollout knobs. +// +// VERIFIED against the installed mppx@0.8.17 source (2026-08-16, previously +// an unconfirmed in-code claim): `mppx/evm`'s Chains.ts defines exactly four +// chain ids (base 8453, baseSepolia 84532, celo 42220, celoSepolia +// 11142220), and Assets.ts's known-USDC registry covers only those same four +// - a stock client's Charge.ts resolves an accepted currency via +// Assets.matches() against ONLY this registry, so a challenge for any other +// chain has nothing for it to auto-sign without the caller manually passing +// a raw address + explicit network override. Mainnets only, hence {8453, +// 42220}. scripts/test-mpp-shim-mppx-registry.js locks this against the +// installed package so a future mppx bump that adds a mainnet fails loudly +// here instead of silently leaving that chain's challenge unoffered. const DEFAULT_CHALLENGE_CHAIN_IDS = new Set([8453, 42220]); /** @param {number} chainId */ -function challengeEnabledForChain(chainId) { +export function challengeEnabledForChain(chainId) { const raw = (process.env.MPP_CHALLENGE_NETWORKS || "").trim(); if (!raw) return DEFAULT_CHALLENGE_CHAIN_IDS.has(chainId); if (raw.toLowerCase() === "all") return true; diff --git a/src/operator.js b/src/operator.js index 05bad11e..86d056f5 100644 --- a/src/operator.js +++ b/src/operator.js @@ -3,7 +3,7 @@ // AGENT402_OPERATOR_TOKEN via a Secure/HttpOnly/SameSite session cookie set at // POST /__operator/login (never a ?token= URL — security audit A402-07). // Nothing here is shown publicly; /api/stats remains the safe public surface. -import { ledgerShell, ledgerFooterCompact, esc } from "./ledger-chrome.js"; +import { ledgerShell, ledgerFooterCompact, esc, jsonScriptTag } from "./ledger-chrome.js"; // Minimal, unauthenticated login form. The operator pastes the token; it is // POSTed as JSON to /__operator/login (body, not URL) which validates it and @@ -29,17 +29,7 @@ export function operatorLoginPage(baseUrl) {
      - + ${ledgerFooterCompact()}`; return ledgerShell({ title: "Operator sign-in - Agent402", @@ -56,6 +46,13 @@ export function operatorPage(baseUrl, data) { const t = data?.totals || {}; const tools = Array.isArray(data?.tools) ? data.tools : []; const recent = Array.isArray(data?.recentCalls) ? data.recentCalls : []; + // Call-volume and revenue are different leaderboards - the per-tool table + // below defaults to sorting by calls, which silently doubles as a revenue + // proxy unless someone thinks to click the Revenue header. Surfacing both + // top-5s side by side makes the divergence visible at a glance. + const topByRevenue = [...tools].sort((a, b) => b.revenueUsd - a.revenueUsd).slice(0, 5); + const topByCalls = [...tools].sort((a, b) => b.calls - a.calls).slice(0, 5); + const railBreakdown = Array.isArray(data?.railBreakdown) ? data.railBreakdown : []; const badge = (r) => r.walletOnly ? `USDC-ONLY` : `FREE-W/POW`; @@ -130,9 +127,9 @@ td a:hover{color:var(--accent)}
      USDC settled
      ${esc(t.viaUSDC ?? 0)}
      on-chain proof at wallet
      PoW (external)
      ${esc(t.viaProofOfWork ?? 0)}
      real free-tier adoption
      Heartbeat probes
      ${esc(t.viaHeartbeat ?? 0)}
      internal /api/hash probe
      -
      Estimated revenue
      $${esc((t.estimatedRevenueUsd ?? 0).toFixed ? t.estimatedRevenueUsd.toFixed(4) : t.estimatedRevenueUsd)}
      counter; chain is truth
      +
      Estimated revenue
      $${esc((t.estimatedRevenueUsd ?? 0).toFixed(4))}
      counter; chain is truth
      Tools served
      ${esc(t.toolsServed ?? 0)}
      distinct slugs
      -
      Uptime
      ${esc(Math.floor((data?.uptimeSeconds ?? 0) / 3600))}h
      since process boot
      +
      Uptime
      ${esc(Math.floor((data?.processUptimeSeconds ?? 0) / 3600))}h
      since process boot
      @@ -150,71 +147,35 @@ td a:hover{color:var(--accent)}
      - +${jsonScriptTag("op-rows-data", tools)} +
    ${ledgerFooterCompact()}`; diff --git a/src/pages.js b/src/pages.js index a39f91dc..bf9c729b 100644 --- a/src/pages.js +++ b/src/pages.js @@ -385,10 +385,10 @@ ${renderHeader("/tools")}
    Agent402 / tools

    ${tools.length} tools, one base URL, zero API keys

    Call any endpoint, get an HTTP 402 quote, and either pay a fraction of a cent in ${RAILS_PAREN} via x402 - or, on the FREE tools, skip the wallet entirely. The catalog is capped - every tool here earns its place and answers its own example on every deploy. Machine-readable: /api/pricing · /openapi.json · /llms.txt.

    -
    +
    ${freeCount} of ${tools.length} tools are free - no wallet needed. Pay with a few seconds of proof-of-work (CPU) instead of USDC. The other ${tools.length - freeCount} (browser, network, memory) settle in USDC because they cost real infrastructure to run. Look for the FREE badge below.
    ${sections} - +
    ${renderFooter()} @@ -588,15 +588,38 @@ export function openapiSpec(baseUrl, catalog) { }; const props = discovery?.inputSchema?.properties ?? {}; const required = discovery?.inputSchema?.required ?? []; + // Every route accepts Idempotency-Key; only PoW-eligible (non-wallet-only) + // routes accept X-Pow-Solution as an alternative to x402 payment. Neither + // was declared as a parameter before - a caller had to already know these + // headers exist from prose docs, not from the machine-readable spec. + const headerParams = [ + { + name: "Idempotency-Key", + in: "header", + required: false, + description: "Optional client-supplied key. Replaying the same key with the same payment/PoW credential and request body returns the original result instead of charging again.", + schema: { type: "string" }, + }, + ...(isComputePayable(tool) ? [{ + name: "X-Pow-Solution", + in: "header", + required: false, + description: `Free-tier alternative to x402 payment: ":" from a solved proof-of-work challenge (GET /api/pow/challenge). Omit when paying via x402.`, + schema: { type: "string" }, + }] : []), + ]; if (method === "GET") { - op.parameters = Object.entries(props).map(([name, schema]) => ({ - name, - in: "query", - required: required.includes(name), - description: schema.description, - schema: { type: schema.type === "number" ? "number" : "string" }, - ...(discovery?.input?.[name] !== undefined ? { example: discovery.input[name] } : {}), - })); + op.parameters = [ + ...Object.entries(props).map(([name, schema]) => ({ + name, + in: "query", + required: required.includes(name), + description: schema.description, + schema: { type: schema.type === "number" ? "number" : "string" }, + ...(discovery?.input?.[name] !== undefined ? { example: discovery.input[name] } : {}), + })), + ...headerParams, + ]; } else { op.requestBody = { required: true, @@ -607,6 +630,7 @@ export function openapiSpec(baseUrl, catalog) { }, }, }; + op.parameters = headerParams; } paths[path] = paths[path] ?? {}; paths[path][method.toLowerCase()] = op; diff --git a/src/payments.js b/src/payments.js index b792e3a1..6ecba8a1 100644 --- a/src/payments.js +++ b/src/payments.js @@ -1156,9 +1156,28 @@ export async function buildPaymentMiddleware({ walletAddress, network, baseUrl, * BEFORE the facilitator settles, so a blocked wallet is never charged — the * buyer gets the standard settle-failure 402 whose receipt carries * errorReason "wallet_blocked" (which the tally middleware records as a - * settle_failed event, so blocks are visible in PostHog). EVM payers are - * matched from the signature-covered EIP-3009 authorization.from; other - * schemes match when their payload carries a recognizable payer field. + * settle_failed event, so blocks are visible in PostHog). + * + * NON-EVM PAYER ENRICHMENT (2026-08-16): beforeSettle only ever receives the + * raw, UNVERIFIED client payload (@x402/core's settlePayment() builds its own + * { paymentPayload, ... } context straight from the caller's argument — it + * never carries the verify() result). For SVM/Stellar/AVM's exact schemes the + * payer is never on that raw payload at all — it's derived by decoding the + * signed transaction, and only appears as a `payer` field on the verify + * RESULT (confirmed directly against the installed SDKs: @x402/svm, /stellar + * and /avm's facilitator verify() all return `{ isValid: true, payer }`). + * Before this fix, blockedPayerFromPayload only ever matched EVM's + * signature-covered authorization.from — a blocked wallet trivially evaded + * the ban by paying on Solana, Stellar, or Algorand instead. + * registerWalletBlocklistPayerEnrichment below closes that gap the only way + * @x402/core's hook API allows: onAfterVerify DOES receive the verify result, + * but can't itself abort settlement (a thrown/rejecting hook there is caught + * and only logged - see runAfterVerifyHooks). So it stashes the verified + * payer directly onto the SAME paymentPayload object instance that + * beforeSettle will receive moments later in the same request (verifyPayment + * and settlePayment both build their context from the exact object reference + * passed in by the caller - never a clone - so this is safe, request-scoped, + * and needs no external cache/keying). */ export function blockedPayerFromPayload(paymentPayload) { const raw = (process.env.WALLET_BLOCKLIST || "").trim(); @@ -1171,6 +1190,7 @@ export function blockedPayerFromPayload(paymentPayload) { paymentPayload?.payload?.authorization?.from, // EVM exact scheme (signature-covered) paymentPayload?.payload?.payer, paymentPayload?.payer, + paymentPayload?.__verifiedPayer, // SVM/Stellar/AVM — see registerWalletBlocklistPayerEnrichment ]; for (const c of candidates) { const normalized = normalizePayerAddress(c); @@ -1179,7 +1199,22 @@ export function blockedPayerFromPayload(paymentPayload) { return null; } +export function registerWalletBlocklistPayerEnrichment(server) { + server.onAfterVerify((ctx) => { + const payer = ctx?.result?.payer; + const payload = ctx?.paymentPayload; + if (payer && payload && !payload.payload?.authorization?.from) { + try { + payload.__verifiedPayer = payer; + } catch { + /* non-extensible payload object — blocklist just won't cover this one payment */ + } + } + }); +} + function registerWalletBlocklistHook(server) { + registerWalletBlocklistPayerEnrichment(server); server.onBeforeSettle((ctx) => { const blocked = blockedPayerFromPayload(ctx?.paymentPayload); if (!blocked) return; diff --git a/src/playground.js b/src/playground.js index 41be3333..51532d52 100644 --- a/src/playground.js +++ b/src/playground.js @@ -9,7 +9,7 @@ // user-controlled values never reach the DOM un-escaped. This pattern is // carried over from the original pre-migration code. -import { ledgerShell, ledgerFooterCompact, esc } from "./ledger-chrome.js"; +import { ledgerShell, ledgerFooterCompact, esc, jsonScriptTag } from "./ledger-chrome.js"; import { toolList } from "./pages.js"; import { isComputePayable } from "./pow.js"; @@ -43,10 +43,6 @@ export function playgroundPage(baseUrl, catalog) { : undefined, }; }); - // Neutralize so a tool description cannot break out of the inline - // script tag. JSON.stringify already escapes quotes; this is the HTML trap. - const toolsJson = JSON.stringify(toolsPayload).replace(/var BASE=${JSON.stringify(baseUrl)};
    Agent402 / playground
    @@ -108,7 +103,7 @@ export function playgroundPage(baseUrl, catalog) {
    -
    +
    @@ -122,351 +117,8 @@ export function playgroundPage(baseUrl, catalog) {
    ${ledgerFooterCompact()} -`; +${jsonScriptTag("pg-tools-data", toolsPayload)} +`; return ledgerShell({ title, diff --git a/src/quickstart.js b/src/quickstart.js index 6a4b33e9..4148f5c8 100644 --- a/src/quickstart.js +++ b/src/quickstart.js @@ -280,34 +280,8 @@ const res = await payFetch("https://agent402.tools/api/extract", {
    ${ledgerFooterCompact()} -`; + +`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/quickstart", extraCss, body }); } diff --git a/src/replay-guard.js b/src/replay-guard.js index a88d103e..9a8b50db 100644 --- a/src/replay-guard.js +++ b/src/replay-guard.js @@ -20,10 +20,39 @@ * retry of the still-valid authorization can succeed. It never blocks a payer * from re-using an authorization the chain hasn't consumed. * - * Pure, offline, no network — unit-testable in isolation. + * CROSS-REPLICA (2026-08-16): prod runs multiple replicas (RATE_LIMIT_REPLICAS), + * and the guard below was per-process only - a concurrent replay landing on + * TWO DIFFERENT replicas within the same request window was invisible to it + * (each replica's inFlight Set only sees its own traffic). The chain's own + * nonce single-use property still prevented a double-CHARGE, but the HANDLER + * ran twice - for a tool that makes a real paid upstream call, that is a real + * cost duplication bounded only by how fast an attacker can fire one + * authorization at two replicas. + * + * Now backed by the SAME Redis connection shared-limit.js already maintains + * (via getSharedRedisClient - one connection per process, not a second one to + * the same server) when REDIS_URL is configured and reachable. FAILS OPEN to + * the original per-process Map/Set on any Redis absence or failure - this is + * the opposite fail-direction from shared-limit.js's rate limiter, and + * deliberately so: that limiter protects a metered FREE-TIER budget, where + * failing open means unmetered free access (a direct loss). This guard is + * documented defense-in-depth on top of a chain-enforced guarantee that holds + * with or without it, so degrading to "exactly today's per-process guarantee" + * during a Redis blip is strictly better than refusing every paid call over + * an optimization layer going dark. */ import { createHash } from "node:crypto"; import { paymentHeaderOf } from "./payer.js"; +import { getSharedRedisClient } from "./shared-limit.js"; + +// Generous vs. any realistic single-request duration (the STT margin cap's +// 60s upstream timeout is the longest known handler in this catalog) - long +// enough that a healthy request always clears it via settle()/release() +// before it matters, short enough that a replica that crashes mid-request +// without ever releasing its claim self-heals well within a minute rather +// than leaving that nonce artificially blocked. +const INFLIGHT_TTL_SECONDS = 120; +const REDIS_PREFIX = "replay:"; /** * Stable replay identity for the x402 payment credential on this request, or @@ -54,24 +83,34 @@ export function paymentReplayKey(req) { } /** - * In-memory replay guard. `consumed` maps a settled nonce → the time it settled; - * `inFlight` is the set of nonces whose gated call is currently mid-settle. + * Replay guard. Redis-backed (shared across replicas) when configured and + * reachable; otherwise the original per-process fallback below. `consumed` + * maps a settled nonce → the time it settled; `inFlight` is the set of nonces + * whose gated call is currently mid-settle. All three methods are now async + * (a Redis round trip on the common path) - the server.js call site already + * runs inside an async middleware. * * Bounded: entries expire after `ttlMs` (well past any realistic authorization - * validity window) and total consumed entries are capped at `maxEntries` with - * FIFO eviction. Eviction is always safe — once a nonce has settled on-chain it - * is permanently dead there, so forgetting it in memory cannot enable a replay + * validity window) and total LOCAL consumed entries are capped at `maxEntries` + * with FIFO eviction (Redis expires its own keys via TTL, no separate cap + * needed). Eviction is always safe — once a nonce has settled on-chain it + * is permanently dead there, so forgetting it cannot enable a replay * (the facilitator would reject it anyway); this guard only makes the rejection * earlier and cheaper. */ export function createReplayGuard({ ttlMs = 60 * 60 * 1000, maxEntries = 50_000 } = {}) { + // Local (per-process) fallback state — used whenever Redis is unset or a + // call to it fails, so a single replica or a Redis outage degrades to + // exactly the pre-Redis guarantee, never to zero protection. const consumed = new Map(); // key -> settledAt (ms) const inFlight = new Set(); + const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000)); - // Prune expired consumed entries. Keys are inserted in non-decreasing settle - // time (each key is consumed at most once, always with the current clock), so - // the oldest sit at the head of the Map and we can stop at the first live one. - function prune(now) { + // Prune expired local consumed entries. Keys are inserted in non-decreasing + // settle time (each key is consumed at most once, always with the current + // clock), so the oldest sit at the head of the Map and we can stop at the + // first live one. + function pruneLocal(now) { const cutoff = now - ttlMs; for (const [k, at] of consumed) { if (at < cutoff) consumed.delete(k); @@ -86,15 +125,39 @@ export function createReplayGuard({ ttlMs = 60 * 60 * 1000, maxEntries = 50_000 * "inflight" — an identical authorization is mid-settle (concurrent replay) * "consumed" — this authorization already settled (sequential replay) */ - begin(key, now = Date.now()) { - prune(now); + async begin(key, now = Date.now()) { + const c = await getSharedRedisClient(); + if (c) { + try { + if (await c.get(REDIS_PREFIX + "c:" + key)) return "consumed"; + // SET NX is the atomic claim: two replicas racing this same key + // cannot both receive a non-null reply, which is exactly the + // property inFlight.add() gave for free within one process. + const claimed = await c.set(REDIS_PREFIX + "f:" + key, String(now), { NX: true, EX: INFLIGHT_TTL_SECONDS }); + return claimed ? "ok" : "inflight"; + } catch (e) { + console.error("[replay-guard] redis begin failed, falling back to local state:", e.message); + // fall through to the local path below + } + } + pruneLocal(now); if (consumed.has(key)) return "consumed"; if (inFlight.has(key)) return "inflight"; inFlight.add(key); return "ok"; }, /** Mark the nonce consumed — call only when the gated call was granted (200). */ - settle(key, now = Date.now()) { + async settle(key, now = Date.now()) { + const c = await getSharedRedisClient(); + if (c) { + try { + await c.del(REDIS_PREFIX + "f:" + key); + await c.set(REDIS_PREFIX + "c:" + key, String(now), { EX: ttlSeconds }); + return; + } catch (e) { + console.error("[replay-guard] redis settle failed, falling back to local state:", e.message); + } + } inFlight.delete(key); while (consumed.size >= maxEntries) { const oldest = consumed.keys().next().value; @@ -105,10 +168,20 @@ export function createReplayGuard({ ttlMs = 60 * 60 * 1000, maxEntries = 50_000 }, /** Release the nonce — call when the gated call was NOT granted, so a * legitimate retry of the still-valid authorization can proceed. */ - release(key) { + async release(key) { + const c = await getSharedRedisClient(); + if (c) { + try { + await c.del(REDIS_PREFIX + "f:" + key); + return; + } catch (e) { + console.error("[replay-guard] redis release failed, falling back to local state:", e.message); + } + } inFlight.delete(key); }, - /** Introspection for tests / stats. */ + /** Introspection for tests / stats — LOCAL fallback state only; Redis + * state is shared and has no single-process notion of "size". */ _state() { return { consumed: consumed.size, inFlight: inFlight.size }; }, diff --git a/src/revenue-live.js b/src/revenue-live.js index 2d80bf2e..6ef353d0 100644 --- a/src/revenue-live.js +++ b/src/revenue-live.js @@ -1330,227 +1330,7 @@ export function revenueChartSection() {
    view as table
    - `; + `; } export function revenuePage(baseUrl, snap) { @@ -1608,7 +1388,7 @@ export function revenuePage(baseUrl, snap) {
    `; }; const body = ` -
    +
    $ GET /api/revenue

    Live revenue.

    @@ -1632,7 +1412,7 @@ export function revenuePage(baseUrl, snap) {

    Don't take our word for it: x402scan indexes our on-chain settlements independently → Their totals count all traffic to our wallets - including our own canary and test buys - so they read higher than the external-only figures above. Their seller row also groups our upstream spending wallet in with the treasury, and that wallet receives the revenue from the tools that fund external purchases, so part of what appears there as demand is our own self-funding loop rather than a third party paying us. Both figures are correct; they measure different things, and the external-only series above is the one that answers "did someone else pay for this".

    ${mppSection(snap.mpp)}
    -
    + ${ledgerFooterCompact(baseUrl)}`; return ledgerShell({ title, description, canonical, baseUrl, activePath: "/revenue", diff --git a/src/sdk-playground.js b/src/sdk-playground.js index f1805bc0..fbaff8a7 100644 --- a/src/sdk-playground.js +++ b/src/sdk-playground.js @@ -20,16 +20,17 @@ console.log(result);`, }, { label: "Find tools by keyword", - code: `// Search for tools matching a query + code: `// Search for tools matching a query - /api/find is free and +// unpaywalled, so this skips the proof-of-work step entirely. const result = await callTool("find", { q: "geocode" -}, { path: "/api/find", method: "GET" }); +}, { path: "/api/find", method: "GET", free: true }); console.log(result);`, }, { label: "Generate a UUID", code: `// Generate a v4 UUID -const result = await callTool("uuid", {}); +const result = await callTool("uuid", {}, { path: "/api/uuid", method: "GET" }); console.log(result);`, }, { @@ -45,7 +46,7 @@ console.log(result);`, { label: "Base64 encode", code: `// Encode text to base64 -const result = await callTool("base64-encode", { +const result = await callTool("base64", { text: "Agent402 is awesome" }); console.log(result);`, @@ -95,8 +96,7 @@ export function sdkPlaygroundPage(baseUrl) { `; const pageBody = ` - -
    +

    HomePlayground › SDK

    SDK Playground

    Write code and run it against the live API. Proof-of-work handles payment automatically. Based on agent402-client.

    @@ -126,119 +126,7 @@ export function sdkPlaygroundPage(baseUrl) {
    ${ledgerFooterCompact()} -`; +`; return ledgerShell({ title, diff --git a/src/sell.js b/src/sell.js index cf2ee087..189ba342 100644 --- a/src/sell.js +++ b/src/sell.js @@ -89,23 +89,14 @@ function costRow([label, value, tone]) { const formHtml = `
    Register in one call
    +
    -
    Free, no account - we probe your origin's x402 surface and list you if it answers. Unreachable sellers drop out of routing (never off the roster) until they recover.
    +
    Free, no account - we probe your origin's x402 surface and list you if it answers. Unreachable sellers drop out of routing (never off the roster) until they recover.
    - `; + `; export function sellPage(baseUrl) { const canonical = `${baseUrl}/sell`; diff --git a/src/seo.js b/src/seo.js index 7a88d470..5cc49e19 100644 --- a/src/seo.js +++ b/src/seo.js @@ -6,6 +6,7 @@ import { BLOG_POSTS } from "./blog.js"; import { ADAPTERS } from "./adapter-docs.js"; import { RAILS, RAILS_OR } from "./rails.js"; import { CHAIN_PAGES } from "./market-page.js"; +import { EXEC_TIERS } from "./tools/route-execute.js"; // Computed ONCE when this module loads (i.e. once per deploy, since Railway // restarts the process), not per-request. Every sitemap lastmod below reuses @@ -213,9 +214,30 @@ export function sitemapSkills(baseUrl) { return subSitemap([{ loc: `${baseUrl}/skills`, priority: "0.8" }, ...skillSlugs().map((s) => ({ loc: `${baseUrl}/skills/${s}`, priority: "0.8" }))], lastmod); } +// Trims a tier dollar amount to the shortest exact representation (2 decimals +// when that's exact, else 3) so $0.01/$0.005/$3.30/$3.00 all read naturally. +const fmtExecTierUsd = (n) => { + const s3 = n.toFixed(3); + return s3.endsWith("0") ? n.toFixed(2) : s3; +}; + export function llmsTxt(baseUrl, catalog) { const tools = toolList(catalog); const powCount = tools.filter(isComputePayable).length; + // Derived from EXEC_TIERS, not hand-typed - a hardcoded list here is exactly + // how the $3.30 route-execute-pro tier (added 2026-08-04) went missing from + // this summary for weeks: a new tier landed in route-execute.js and nobody + // remembered to touch this unrelated prose file too. Deriving it means a + // future 5th tier can't repeat the same silent omission. + const execTierSentence = EXEC_TIERS.map((t, i) => { + // Real route path, same derivation as buildRouteExecuteTool() itself + // (route-execute.js): "route-execute-plus" -> suffix "-plus" -> + // /api/route/execute-plus, never /api/route/route-execute-plus. + const routeSuffix = t.slug.replace("route-execute", ""); + return i === 0 + ? `$${fmtExecTierUsd(t.execPriceUsd)} covers tools <= $${fmtExecTierUsd(t.underlyingMaxUsd)}` + : `\`/api/route/execute${routeSuffix}\` at $${fmtExecTierUsd(t.execPriceUsd)} covers <= $${fmtExecTierUsd(t.underlyingMaxUsd)}`; + }).join(", "); // The llms.txt spec (llmstxt.org) wants: an H1, one summary blockquote, then // free-form "info" prose (NO headings), then H2 sections whose bodies are @@ -291,13 +313,15 @@ We state it this way deliberately: the honest guarantee is "settlement ordering **MPP clients are first-class (dual-stack).** Every paid endpoint also speaks MPP (Machine Payments Protocol, the IETF-track \`Payment\` HTTP auth scheme): the same 402 carries a \`WWW-Authenticate: Payment\` challenge (evm charge, EIP-3009 USDC), \`Authorization: Payment\` credentials settle on-chain identically to x402, and settled responses return a signed \`Payment-Receipt\` header. An \`mppx\` client (\`Fetch.from\` with \`evm.charge\`) works out of the box - same URL, same price, same settlement as x402, whichever dialect your client speaks. +**How to read our 402 if you only speak one dialect.** The same response carries BOTH headers, always - \`WWW-Authenticate: Payment\` is additive, never a replacement for the real x402 \`PAYMENT-REQUIRED\` header (full \`accepts\` array, \`exact\` scheme, EIP-3009). A client that hard-fails on an unrecognized \`WWW-Authenticate\` scheme instead of also checking for \`PAYMENT-REQUIRED\` will bail with something like "no supported rail" on a 402 it could have paid - this has happened at least once (see issue #794). If your parser only understands one of the two dialects, check for the header it understands FIRST rather than trusting whichever header happens to be read first; do not treat an unrecognized \`WWW-Authenticate\` scheme as "this server has no payment option for me." + ## Key machine surfaces - [/api/search](${baseUrl}/api/search): **front door** - live web search (title, URL, snippet). Start here to discover pages; follow with extract or answer - [/api/answer](${baseUrl}/api/answer): **front door** - cited answer grounded in live web search results - [/api/search-news](${baseUrl}/api/search-news): live news search for current events / headlines - [/api/find](${baseUrl}/api/find): resolve a plain-language task to the best-matching tools with route, price, input schema, and a ready example (GET \`?q={task}\` or POST \`{"task":"..."}\`) - long-tail discovery behind the flagships - [/api/route](${baseUrl}/api/route): Smart Order Router - rank tools across every x402 seller crawled from public registries; \`include:"external"\` excludes Agent402 for neutral cross-seller discovery -- [/api/route/execute](${baseUrl}/api/route/execute): the SOR that also PAYS. Send a task, and Agent402 resolves the best-matching tool, pays the seller over x402 on your behalf (any proven seller in the open index, not just ours), and relays the result with a receipt - one payment, one request, one wallet. You never hold a wallet on their chain or sign up with them. \`{"task":"...","include":"external"}\`. Proportional tiers: $0.01 covers tools <= $0.005, \`/api/route/execute-plus\` at $0.05 covers <= $0.04, \`/api/route/execute-max\` at $0.55 covers <= $0.50 - an over-cap task gets a self-correcting 409 naming the tier that fits +- [/api/route/execute](${baseUrl}/api/route/execute): the SOR that also PAYS. Send a task, and Agent402 resolves the best-matching tool, pays the seller over x402 on your behalf (any proven seller in the open index, not just ours), and relays the result with a receipt - one payment, one request, one wallet. You never hold a wallet on their chain or sign up with them. \`{"task":"...","include":"external"}\`. Proportional tiers: ${execTierSentence} - an over-cap task gets a self-correcting 409 naming the tier that fits - [/api/index](${baseUrl}/api/index): JSON snapshot of every seller indexed (health, routable flag, crawl history) - [/api/leaderboard](${baseUrl}/api/leaderboard): public on-chain ranking of x402 sellers by Base USDC settled volume (pipeline: Bazaar discovery → \`eth_getLogs\` on Base USDC → per-call ceiling filter → aggregate by payTo; params \`?sort=usd|calls\`, \`?top=N\`, \`?include=external|all\`) - same data as the MCP tool \`sellers.list\` and the \`agent402-client\` SDK method \`topSellers()\` - [/.well-known/x402](${baseUrl}/.well-known/x402): one-fetch service manifest (identity, payment options, capability map, MCP, trust signals) diff --git a/src/server.js b/src/server.js index e8a3bc8a..3b2b5dca 100644 --- a/src/server.js +++ b/src/server.js @@ -220,7 +220,7 @@ const trialToolLimiter = createRateLimiter("trial-tool", { perMin: TRIAL_PER_TOO const trialIpLimiter = createRateLimiter("trial-ip", { perMin: TRIAL_IP_MIN, perHour: TRIAL_IP_HOUR }); const TRIAL_LIMITS_LABEL = `${TRIAL_PER_TOOL_HOUR} per tool per hour, ${TRIAL_IP_HOUR} per hour per client`; import { recordRefundOwed, receiptProvesCharge, listRefunds, markRefundPaid, markRefundVoid, claimRefundForSend, refundTotals } from "./refund-ledger.js"; -import { recordServedCall, recordChargedFailure, networkFromPaymentResponse, decodeSettleReceipt, getStats, getOperatorBreakdown, dbHealthy, statsPersistent, getDailyCalls, dailyCallsRecordingSince, getDailyUpstreamCalls } from "./stats.js"; +import { recordServedCall, recordChargedFailure, networkFromPaymentResponse, decodeSettleReceipt, getStats, getOperatorBreakdown, dbHealthy, statsPersistent, getDailyCalls, dailyCallsRecordingSince, getDailyUpstreamCalls, getSellerRegistrations } from "./stats.js"; import { timingSafeEqual, createHash, randomUUID, randomBytes } from "node:crypto"; const PORT = process.env.PORT || 3000; @@ -1148,17 +1148,28 @@ app.use((_req, res, next) => { res.setHeader("X-Permitted-Cross-Domain-Policies", "none"); res.setHeader( "Content-Security-Policy", - // script-src carries one narrow exception: unpkg.com, for the homepage's - // pinned, SRI-verified d3 + topojson-client tags (the dot-map, Aug 2026 - // revamp - the site's first-ever third-party script, an explicit, - // knowing tradeoff against the "everything self-hosted" posture used - // everywhere else, incl. fonts). A specific host, never a wildcard or - // 'unsafe-eval' — SRI on the tags themselves is a second, independent - // layer (a compromised unpkg response with a mismatched hash is refused - // by the browser before it ever executes). connect-src's existing - // 'https:' already covers the map's runtime fetch of the world-atlas - // geometry from jsdelivr, so no change needed there. - "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; connect-src 'self' https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" + // script-src drops 'unsafe-inline' (2026-08-16): every page-behavior + // script site-wide now lives in a real file under /js/:file (strict + // filename allowlist, no path traversal - see server.js's /js/:file + // route) or a dedicated route with its own scoped CSP (the SDK + // playground's eval sandbox at /sdk-playground/sandbox). This is + // defense-in-depth, not a fix for a live exploit — the site already + // manually-escapes all third-party/user content (crawled seller names, + // wish-board text, etc.) rather than relying on a templating engine's + // automatic escaping, across hundreds of call sites; removing + // 'unsafe-inline' means a future missed esc() call can no longer be + // turned into a working + ${ledgerFooterCompact()}`; diff --git a/src/tollbooth-waitlist.js b/src/tollbooth-waitlist.js index 2e1f1918..bc45fad4 100644 --- a/src/tollbooth-waitlist.js +++ b/src/tollbooth-waitlist.js @@ -76,7 +76,7 @@ export function tollboothWaitlistPage(baseUrl, { plan = "team", kind = "waitlist

    ${esc(p.h)}

    ${esc(p.lead)}

    -
    +
    @@ -108,64 +108,7 @@ export function tollboothWaitlistPage(baseUrl, { plan = "team", kind = "waitlist

    Submissions are stored privately on our server (Postgres on Railway) and never appear in any public repo. We use them to email you about your plan and nothing else.

    - +
    ${ledgerFooterCompact()}`; diff --git a/src/webhooks.js b/src/webhooks.js index 93385e0a..9c176abe 100644 --- a/src/webhooks.js +++ b/src/webhooks.js @@ -31,7 +31,7 @@ export function webhooksPage(baseUrl) { -
    +

    HomeDocs › Webhooks & Callbacks

    Webhooks & Callbacks

    How to handle async workflows, retries, and long-running tool chains with Agent402.

    @@ -106,7 +106,7 @@ await a.call("memory-write", { Quickstart - get your first call working in 60 seconds
    Documentation - full API reference

    -
    + ${ledgerFooterCompact()}`; diff --git a/src/x402-index.js b/src/x402-index.js index c7201417..8da9174f 100644 --- a/src/x402-index.js +++ b/src/x402-index.js @@ -38,8 +38,9 @@ import { CHAIN_PAGES, marketSellers } from "./market-page.js"; import { WELL_KNOWN_PATH, discoveryNote } from "./discovery-note.js"; import { acceptsFromLive402, quoteFromAccepts, probeMethodsFor, isQuoteResponse } from "./x402-live-quote.js"; import { summarize, fmtUsd, fmtPct } from "./economy.js"; -import { rankBy, canonicalHost } from "./leaderboard.js"; +import { rankBy, canonicalHost, getLeaderboardSnapshot } from "./leaderboard.js"; import { routeExecuteHint } from "./tools/route-execute.js"; +import { recordSellerRegistrationSeen } from "./stats.js"; // RAILS caip2 -> CHAIN_PAGES key, same join the homepage's by-chain strip uses // (see ledger-home.js) so /index's own row derives the same way: page @@ -148,6 +149,11 @@ export function validateOriginInput(raw, { selfOrigin } = {}) { export async function registerOrigin(origin, { crawl } = {}) { const existing = cache.get(origin); if (existing && !existing.error) { + // Only a self-serve-submitted origin belongs in seller_registrations - this + // early-return path also serves origins already known from Bazaar/registry + // discovery, which never went through /sell and would misrepresent an + // ecosystem seller as one of ours if recorded here. + if (submittedSeeds.has(origin)) recordSellerRegistrationSeen(origin, { settled: originHasSettled(origin) }); return { listed: true, origin, seller: sellerSummary(origin, existing) }; } // Cap applies only to origins that would grow the submitted set. An origin @@ -165,11 +171,32 @@ export async function registerOrigin(origin, { crawl } = {}) { discoveredSeeds.add(origin); persistSubmittedSeeds(); if (!cache.has(origin) && crawl) cache.set(origin, { ...v, fetchedAt: Date.now() }); + recordSellerRegistrationSeen(origin, { settled: originHasSettled(origin) }); return { listed: true, origin, seller: sellerSummary(origin, cache.get(origin) || v) }; } return { listed: false, origin, error: String(v?.error || "no x402 surface found (manifest, OpenAPI, or Bazaar entry)") }; } +// Has this origin's leaderboard row settled at least one payment? Joins on +// canonical host (leaderboard rows carry `origins: string[]`) rather than +// payTo address - the leaderboard already groups by host when a homepage is +// known, and every origin here already has a URL we can hash the same way, +// so this needs no new payTo-matching plumbing. Best-effort: any shape +// surprise in the snapshot (still warming, scan error) reads as "not yet +// observed settling", never a throw. +function originHasSettled(origin) { + const host = canonicalHost(origin); + if (!host) return false; + try { + const snap = getLeaderboardSnapshot(); + return (snap?.leaderboard || []).some( + (row) => (row.callsSettled || 0) > 0 && (row.origins || []).some((o) => canonicalHost(o) === host) + ); + } catch { + return false; + } +} + function sellerSummary(origin, v) { return { displayName: v.manifest?.name || origin.replace(/^https?:\/\//, ""), @@ -2056,11 +2083,26 @@ async function runCrawl() { crawlCycle += 1; const ordered = seeds.length ? [...seeds.slice(start), ...seeds.slice(0, start)] : seeds; await runPool(ordered, CRAWL_CONCURRENCY, crawlSeller); + recordSubmittedSellerObservations(); } finally { crawlInFlight = false; } } +// Post-cycle churn/conversion pass over ONLY self-serve-submitted origins +// (not the operator-curated DEFAULT_SEEDS or registry-discovered sellers - +// seller_registrations tracks /sell signups specifically). An origin whose +// crawl failed this cycle is skipped entirely: last_routable_seen simply +// stops advancing, which is the churn signal itself - stamping "now" on a +// failed probe would hide the very thing this table exists to show. +function recordSubmittedSellerObservations() { + for (const origin of submittedSeeds) { + const entry = cache.get(origin); + if (!entry || entry.error) continue; + recordSellerRegistrationSeen(origin, { settled: originHasSettled(origin) }); + } +} + /** * Boot the periodic crawler. Safe to call multiple times — subsequent calls are * no-ops. The first crawl runs immediately (non-blocking) so the page has data