// Site report from Node: check status and price (free, lordevo-x402 SDK), pay with an x402 client, poll, save the // client-ready files. // npm i lordevo-x402@>=0.1.5 @x402/fetch @x402/evm viem // SHOP=http://127.0.0.1:4999 TARGET=https://lordevo.com BUYER_PRIVATE_KEY=0x... node site-report.mjs // SHOP defaults to a local dev shop (dry mode, mock facilitator: no real money). For the real shop use // SHOP=https://shop.lordevo.com and a key holding USDC on Base, only once the service is on sale. // Optional: BRAND_NAME, BRAND_LOGO_URL (white-label), MAX_PAGES (1-10), OUT_DIR (default .), MAX_PRICE_USD (default 1), // POLL_MS (default 20000), LORDEVO_SDK (file path of the SDK's src/index.js, if it is not installed from npm). import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { x402Client, wrapFetchWithPayment } from '@x402/fetch'; import { registerExactEvmScheme } from '@x402/evm/exact/client'; import { privateKeyToAccount } from 'viem/accounts'; const env = process.env; const SHOP = (env.SHOP || 'http://127.0.0.1:4999').replace(/\/$/, ''); const TARGET = env.TARGET || 'https://lordevo.com'; const OUT = env.OUT_DIR || '.'; const MAX_PRICE_USD = Number(env.MAX_PRICE_USD || 1); const { catalog } = await import(env.LORDEVO_SDK ? pathToFileURL(path.resolve(env.LORDEVO_SDK)).href : 'lordevo-x402'); // 1. Free: status, price and endpoint come from the shop's own catalog. const cat = await catalog({ baseUrl: SHOP }); const svc = cat.services.find(s => s.slug === 'site-report'); const tier = svc?.tiers.find(t => t.id === 'standard'); if (!svc || !tier) throw new Error('site-report is not listed by this shop.'); console.log(`site-report: ${svc.status}; price ${svc.price_status ? 'to be confirmed' : `${tier.price_usd} USD`}; on sale: ${tier.on_sale}`); if (!tier.on_sale) process.exit(0); // nothing to buy yet // 2. A paying fetch: answers the 402 with a signed USDC payment for exactly the quoted terms, up to MAX_PRICE_USD. const client = new x402Client(); registerExactEvmScheme(client, { signer: privateKeyToAccount(env.BUYER_PRIVATE_KEY) }); client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => { if (Number(r.amount) / 1e6 > MAX_PRICE_USD) return { abort: true, reason: `Price above MAX_PRICE_USD ${MAX_PRICE_USD}` }; }); const pay = wrapFetchWithPayment(fetch, client); // 3. The request. Bad input is never charged: an unpaid request gets the 402 with input_hint, a paid one a 400 (also 403/429). const body = { url: TARGET, scope: { max_pages: Number(env.MAX_PAGES || 10), audience: 'agency', format: 'all', ...(env.BRAND_NAME ? { brand: { name: env.BRAND_NAME, ...(env.BRAND_LOGO_URL ? { logo_url: env.BRAND_LOGO_URL } : {}) } } : {}), }, authorization: { attested: true, statement: 'I own this website or have written permission from its owner to assess it.' }, }; const res = await pay(tier.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); let j = await res.json(); if (res.status >= 400) { console.error(`HTTP ${res.status} ${j.error}: ${j.message || ''} (charged: ${j.charged ?? false})`); process.exit(1); } console.log(`HTTP ${res.status}, status ${j.status}, delivery ${j.delivery_id}`); // 4. 202 = running (already charged: something was measured). Poll result_url for free every 15-30 s. const pollMs = Number(env.POLL_MS || 20000); while (j.status === 'running') { console.log(`running: ${j.progress || ''}`); await new Promise(r => setTimeout(r, pollMs)); j = await (await fetch(j.result_url)).json(); } if (j.status !== 'done') { console.error(`Report ${j.status}: ${j.error || ''} Keep delivery_id ${j.delivery_id} as your reference.`); process.exit(1); } // 5. Save the JSON and the client-ready documents (the links are private and expire after 30 days). fs.mkdirSync(OUT, { recursive: true }); fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(j.report, null, 2)); for (const [file, url] of [['report.html', j.report_html_url], ['report.md', j.report_markdown_url]]) { const r = await fetch(url); if (!r.ok) throw new Error(`${file}: HTTP ${r.status}`); fs.writeFileSync(path.join(OUT, file), await r.text()); } const s = j.report.summary; console.log(s.executive_summary); console.log(`findings: ${j.report.findings.length}; top: ${s.top_priorities.map(t => `${t.priority} ${t.title}`).join(' | ') || 'none'}`); console.log(`saved report.json, report.html, report.md in ${path.resolve(OUT)}`);