diff --git a/lib/onion.ts b/lib/onion.ts index 168be91..1eaeff7 100644 --- a/lib/onion.ts +++ b/lib/onion.ts @@ -2,6 +2,7 @@ import tls from "node:tls"; import { Agent, type buildConnector, type Dispatcher } from "undici"; import { SocksClient } from "socks"; import { env } from "./env"; +import { paidFetch } from "./paid-fetch"; // Tor routing for .onion targets. Onion addresses don't resolve via DNS, so any // fetch to one must go through a Tor SOCKS5 proxy (socks5h — the proxy resolves @@ -58,7 +59,9 @@ function torDispatcher(): Dispatcher { // Fetch that transparently routes .onion targets through Tor and everything // else through the normal stack. Callers pass their usual RequestInit. export async function smartFetch(url: string, init?: RequestInit): Promise { - if (!isOnion(url)) return fetch(url, init); + // The clearnet path pays an x402 gateway when a key is configured; Tor + // keeps the plain fetch, since the pass and the proof are clearnet things. + if (!isOnion(url)) return paidFetch(url, init); if (!torConfigured()) { throw new Error( "This is a .onion address; set TOR_SOCKS_URL and run a Tor proxy to reach it.", diff --git a/lib/paid-fetch.ts b/lib/paid-fetch.ts new file mode 100644 index 0000000..e590074 --- /dev/null +++ b/lib/paid-fetch.ts @@ -0,0 +1,26 @@ +/** + * The fetch the audit reaches a customer's site with — paying when asked. + * + * A site behind an x402 gateway answers a crawler with 402 and an offer. With + * `X402_PRIVATE_KEY` set, the client signs the offer with the shared crawler + * wallet, buys the pass, files it by origin and presents it on every later + * request to that site, so an audit of a gated site reads the site rather + * than the sales page. Without the key this is the global fetch, unchanged. + * + * Only the page fetch goes through here. Link-status probes and uptime pings + * touch many third-party domains per run, and paying a dollar to learn a + * link's status would be waste; those stay on the plain fetch. + * + * Capped at five dollars a payment. The key is read through a non-literal + * accessor because Next inlines `process.env.NAME` at build time. + */ + +import { createClient } from "@profullstack/x402-client"; + +const key = process.env[["X402", "PRIVATE_KEY"].join("_")]; + +export const x402 = key ? createClient({ key, maxUsd: 5 }) : null; + +export const paidFetch: typeof fetch = x402 + ? (input, init) => x402.fetch(input, init) + : (input, init) => fetch(input, init); diff --git a/package-lock.json b/package-lock.json index d148bb4..8491f03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", + "@profullstack/x402-client": "^0.2.0", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0", @@ -1552,6 +1553,33 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", @@ -1686,6 +1714,22 @@ } } }, + "node_modules/@profullstack/x402-client": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@profullstack/x402-client/-/x402-client-0.2.0.tgz", + "integrity": "sha512-kJGAomE9Tf/ruhsCStaKVXpjklCbOh2UFcMGTIfTqcWaSsW2NFWQRaTxJBLvc6r7Iy2+fpAWzfJnerolI6IrRQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0" + }, + "bin": { + "x402": "bin/x402.js" + }, + "engines": { + "node": ">=20.19" + } + }, "node_modules/@react-email/render": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", diff --git a/package.json b/package.json index 6ca918b..d20c941 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", + "@profullstack/x402-client": "^0.2.0", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0",