agent402-tollbooth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/bots.js +30 -0
- package/example.js +29 -0
- package/index.js +172 -0
- package/package.json +54 -0
- package/pow.js +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# agent402-tollbooth
|
|
2
|
+
|
|
3
|
+
**Open-source, self-hostable x402 "pay-per-crawl". Put it in front of any site or
|
|
4
|
+
API: humans browse free, AI crawlers and agents pay per request** — either in
|
|
5
|
+
USDC over the [x402 protocol](https://x402.org), or for free by solving a
|
|
6
|
+
proof-of-work. No Cloudflare, no Stripe, no Merchant-of-Record, no signup.
|
|
7
|
+
|
|
8
|
+
The big platforms ([Cloudflare](https://stackoverflow.blog/2026/02/26/how-pay-per-crawl-is-reshaping-data-monetization/),
|
|
9
|
+
Stack Overflow) shipped pay-per-crawl as a closed, fiat, you-must-be-on-our-CDN
|
|
10
|
+
feature. This is the open, crypto-native, run-it-yourself version — built on the
|
|
11
|
+
same hardened 402 + proof-of-work machinery as [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install agent402-tollbooth
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use it as Express middleware
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import express from "express";
|
|
23
|
+
import { createTollbooth } from "agent402-tollbooth";
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
|
|
27
|
+
// Humans pass through; known AI crawlers get 402 and must pay or solve a PoW.
|
|
28
|
+
app.use(createTollbooth({ price: "$0.002" }));
|
|
29
|
+
|
|
30
|
+
app.get("/article", (_req, res) => res.send("…your content…"));
|
|
31
|
+
app.listen(3000);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
curl -A "Mozilla/5.0" localhost:3000/article # human -> 200, free
|
|
36
|
+
curl -A "ClaudeBot/1.0" localhost:3000/article # bot -> 402 Payment Required
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The 402 body advertises both rails:
|
|
40
|
+
|
|
41
|
+
```jsonc
|
|
42
|
+
{
|
|
43
|
+
"error": "Payment Required",
|
|
44
|
+
"message": "…humans browse free; bots pay in USDC via x402 or by solving a proof-of-work.",
|
|
45
|
+
"accepts": [{ "scheme": "exact", "network": "base", "maxAmountRequired": "$0.002", "asset": "USDC", "payTo": "0x…", "resource": "/article" }],
|
|
46
|
+
"proofOfWork": { "algorithm": "sha256", "challenge": "…", "difficulty": 18, "token": "…", "rule": "Find a nonce so sha256(challenge+\":\"+nonce) has >= 18 leading zero bits; resend with header X-Pow-Solution: <token>:<nonce>" }
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A crawler that can't (or won't) pay USDC solves the puzzle and retries with
|
|
51
|
+
`X-Pow-Solution: <token>:<nonce>` — sub-second of CPU, single-use, bound to that
|
|
52
|
+
exact URL.
|
|
53
|
+
|
|
54
|
+
## Use it as a reverse proxy (any language/framework)
|
|
55
|
+
|
|
56
|
+
Point it at your existing site — no code changes there:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
TOLLBOOTH_UPSTREAM=https://your-site.com \
|
|
60
|
+
TOLLBOOTH_PAYTO=0xYourWallet \
|
|
61
|
+
npx agent402-tollbooth # listens on :4021, proxies humans free, charges bots
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Accepting USDC (x402)
|
|
65
|
+
|
|
66
|
+
The proof-of-work rail works with **zero config**. To also settle real USDC,
|
|
67
|
+
set `payTo` and supply `verifyX402` — wire it to the standard, audited x402
|
|
68
|
+
server stack (`@x402/express` / your facilitator) rather than reinventing
|
|
69
|
+
settlement:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { paymentMiddleware } from "x402-express"; // or @x402/express
|
|
73
|
+
const x402 = paymentMiddleware(/* your wallet + facilitator config */);
|
|
74
|
+
|
|
75
|
+
app.use(createTollbooth({
|
|
76
|
+
payTo: "0xYourWallet",
|
|
77
|
+
network: "base",
|
|
78
|
+
// Reuse the standard middleware to verify the X-PAYMENT header:
|
|
79
|
+
verifyX402: (req) => new Promise((resolve) =>
|
|
80
|
+
x402(req, { setHeader() {}, status() { return this; }, json() { resolve(false); } }, () => resolve(true))),
|
|
81
|
+
}));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
(PoW is checked first, so an agent without a wallet always has a free path.)
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
| Option | Default | What |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `price` | `"$0.001"` | Advertised price per request (x402 quote) |
|
|
91
|
+
| `payTo` | – | Wallet address; set to advertise a USDC x402 quote |
|
|
92
|
+
| `network` | `"base"` | x402 network |
|
|
93
|
+
| `pow` | `true` | Enable the free proof-of-work rail |
|
|
94
|
+
| `powDifficulty` | `18` | PoW difficulty in leading zero bits (~0.1–0.5s of CPU) |
|
|
95
|
+
| `botUserAgents` | `AI_BOTS` | User-agents to charge (AI/LLM crawlers; search indexers excluded) |
|
|
96
|
+
| `charge(req)` | UA match | Custom predicate for "should this client pay?" |
|
|
97
|
+
| `free(req)` | – | Custom force-allow predicate (wins over `charge`) |
|
|
98
|
+
| `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
|
|
99
|
+
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
100
|
+
|
|
101
|
+
Environment variables: `TOLLBOOTH_UPSTREAM`, `TOLLBOOTH_PAYTO`, `TOLLBOOTH_PRICE`,
|
|
102
|
+
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_SECRET`, `PORT`.
|
|
103
|
+
|
|
104
|
+
## How it decides who pays
|
|
105
|
+
|
|
106
|
+
By default it charges requests whose `User-Agent` matches a known **AI/LLM
|
|
107
|
+
crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider, Google-Extended,
|
|
108
|
+
Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are intentionally
|
|
109
|
+
**not** charged so your SEO indexing stays free. Override with `botUserAgents`,
|
|
110
|
+
or take full control with a `charge(req)` predicate (e.g. charge anything
|
|
111
|
+
without a browser-like `Accept` header, or charge everyone on `/api/*`).
|
|
112
|
+
|
|
113
|
+
## Notes
|
|
114
|
+
|
|
115
|
+
- Proof-of-work tokens are HMAC-signed, expiry-checked, single-use, and bound to
|
|
116
|
+
the exact resource — a solution for `/a` can't be replayed on `/b` or reused.
|
|
117
|
+
- Set a stable `TOLLBOOTH_SECRET` in production so tokens survive restarts.
|
|
118
|
+
- MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
package/bots.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Default set of AI / LLM crawler and agent user-agents to charge.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately AI-focused: traditional search indexers (Googlebot, Bingbot,
|
|
4
|
+
// DuckDuckBot) are NOT here — you almost always want classic SEO indexing to
|
|
5
|
+
// stay free. Override the whole list with `botUserAgents`, or supply your own
|
|
6
|
+
// `charge(req)` predicate for full control.
|
|
7
|
+
export const AI_BOTS = [
|
|
8
|
+
"GPTBot", "OAI-SearchBot", "ChatGPT-User",
|
|
9
|
+
"ClaudeBot", "Claude-Web", "anthropic-ai", "Claude-User",
|
|
10
|
+
"PerplexityBot", "Perplexity-User",
|
|
11
|
+
"CCBot", "Bytespider", "Google-Extended", "Amazonbot",
|
|
12
|
+
"cohere-ai", "Meta-ExternalAgent", "Meta-ExternalFetcher",
|
|
13
|
+
"Applebot-Extended", "Diffbot", "Omgilibot", "ImagesiftBot",
|
|
14
|
+
"YouBot", "Timpibot", "DuckAssistBot", "PetalBot",
|
|
15
|
+
"FriendlyCrawler", "AI2Bot", "Scrapy", "python-requests",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a fast case-insensitive substring matcher over a user-agent list.
|
|
20
|
+
* @param {string[]} list
|
|
21
|
+
* @returns {(userAgent?: string) => boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function makeBotMatcher(list = AI_BOTS) {
|
|
24
|
+
const needles = list.map((s) => s.toLowerCase());
|
|
25
|
+
return (userAgent = "") => {
|
|
26
|
+
const ua = String(userAgent).toLowerCase();
|
|
27
|
+
if (!ua) return false;
|
|
28
|
+
return needles.some((n) => ua.includes(n));
|
|
29
|
+
};
|
|
30
|
+
}
|
package/example.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Runnable demo: a tiny "content site" sitting behind the tollbooth.
|
|
2
|
+
// Humans read it free; AI crawlers get a 402 and must pay (USDC via x402) or
|
|
3
|
+
// solve a proof-of-work.
|
|
4
|
+
//
|
|
5
|
+
// node tollbooth/example.js
|
|
6
|
+
// curl -A "Mozilla/5.0" localhost:4021/article # human -> 200
|
|
7
|
+
// curl -A "ClaudeBot/1.0" localhost:4021/article # bot -> 402
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { createTollbooth } from "./index.js";
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
|
|
13
|
+
app.use(createTollbooth({
|
|
14
|
+
price: "$0.002",
|
|
15
|
+
// Set payTo to advertise a USDC x402 quote (and wire `verifyX402` to settle).
|
|
16
|
+
payTo: process.env.TOLLBOOTH_PAYTO || undefined,
|
|
17
|
+
// Humans pass free; AI crawlers (GPTBot/ClaudeBot/CCBot/PerplexityBot/…) pay.
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
app.use((_req, res) =>
|
|
21
|
+
res.type("html").send("<h1>Premium content</h1><p>Humans read this for free. An AI crawler paid (or solved a proof-of-work) to reach this page.</p>")
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const port = Number(process.env.PORT) || 4021;
|
|
25
|
+
app.listen(port, () => {
|
|
26
|
+
console.log(`Demo site behind the tollbooth → http://localhost:${port}`);
|
|
27
|
+
console.log(` human: curl -A "Mozilla/5.0" localhost:${port}/article`);
|
|
28
|
+
console.log(` bot: curl -A "ClaudeBot/1.0" localhost:${port}/article`);
|
|
29
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent402-tollbooth — an open-source, self-hostable x402 "pay-per-crawl" gate.
|
|
3
|
+
//
|
|
4
|
+
// Put it in front of any site or API: human visitors pass through free, while
|
|
5
|
+
// AI crawlers / agents must pay per request — either in USDC over the x402
|
|
6
|
+
// protocol, or for free by solving a proof-of-work (no wallet, no signup, no
|
|
7
|
+
// Stripe, no Cloudflare). Use it two ways:
|
|
8
|
+
//
|
|
9
|
+
// 1. Express middleware: app.use(createTollbooth({ ... }))
|
|
10
|
+
// 2. Reverse proxy (CLI): TOLLBOOTH_UPSTREAM=https://your-site.com npx agent402-tollbooth
|
|
11
|
+
//
|
|
12
|
+
// The proof-of-work rail works out of the box with zero configuration. To also
|
|
13
|
+
// accept USDC, set `payTo` and supply `verifyX402` (wire it to the standard
|
|
14
|
+
// x402 server middleware / your facilitator — see README).
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { createPow } from "./pow.js";
|
|
17
|
+
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
18
|
+
|
|
19
|
+
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
20
|
+
export { createPow, leadingZeroBits } from "./pow.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the tollbooth Express middleware.
|
|
24
|
+
* @param {object} [config]
|
|
25
|
+
* @param {string} [config.price="$0.001"] advertised price per request
|
|
26
|
+
* @param {string|null} [config.payTo] wallet for the x402 quote (enables USDC rail)
|
|
27
|
+
* @param {string} [config.network="base"] x402 network
|
|
28
|
+
* @param {string} [config.asset="USDC"]
|
|
29
|
+
* @param {boolean} [config.pow=true] enable the free proof-of-work rail
|
|
30
|
+
* @param {number} [config.powDifficulty] PoW difficulty in leading zero bits
|
|
31
|
+
* @param {string[]} [config.botUserAgents] user-agents to charge (default: AI_BOTS)
|
|
32
|
+
* @param {(req)=>boolean} [config.charge] custom "should this client pay?" predicate
|
|
33
|
+
* @param {(req)=>boolean} [config.free] custom force-allow predicate (wins over charge)
|
|
34
|
+
* @param {(req, requirements)=>boolean|Promise<boolean>} [config.verifyX402] USDC settlement check
|
|
35
|
+
* @param {string} [config.resourceBaseUrl] absolute base for the x402 `resource`/PoW binding
|
|
36
|
+
* @param {string} [config.message] human-readable note included in the 402
|
|
37
|
+
*/
|
|
38
|
+
export function createTollbooth(config = {}) {
|
|
39
|
+
const {
|
|
40
|
+
price = process.env.TOLLBOOTH_PRICE || "$0.001",
|
|
41
|
+
payTo = process.env.TOLLBOOTH_PAYTO || null,
|
|
42
|
+
network = process.env.TOLLBOOTH_NETWORK || "base",
|
|
43
|
+
asset = "USDC",
|
|
44
|
+
pow = true,
|
|
45
|
+
powDifficulty,
|
|
46
|
+
powSecret,
|
|
47
|
+
botUserAgents = AI_BOTS,
|
|
48
|
+
charge,
|
|
49
|
+
free,
|
|
50
|
+
verifyX402,
|
|
51
|
+
resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
|
|
52
|
+
message = "This resource charges automated / AI clients per request. Humans browse free; bots pay in USDC via x402 or by solving a proof-of-work.",
|
|
53
|
+
} = config;
|
|
54
|
+
|
|
55
|
+
const isBot = makeBotMatcher(botUserAgents);
|
|
56
|
+
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
57
|
+
|
|
58
|
+
const shouldCharge = (req) => {
|
|
59
|
+
if (typeof free === "function" && free(req)) return false;
|
|
60
|
+
if (typeof charge === "function") return Boolean(charge(req));
|
|
61
|
+
return isBot(req.headers["user-agent"] || "");
|
|
62
|
+
};
|
|
63
|
+
const resourceOf = (req) =>
|
|
64
|
+
(resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + (req.originalUrl || req.url || "/");
|
|
65
|
+
|
|
66
|
+
function tollbooth(req, res, next) {
|
|
67
|
+
if (!shouldCharge(req)) return next();
|
|
68
|
+
const resource = resourceOf(req);
|
|
69
|
+
|
|
70
|
+
const send402 = (extra = {}) => {
|
|
71
|
+
const body = {
|
|
72
|
+
error: "Payment Required",
|
|
73
|
+
message,
|
|
74
|
+
accepts: payTo
|
|
75
|
+
? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }]
|
|
76
|
+
: [],
|
|
77
|
+
...extra,
|
|
78
|
+
};
|
|
79
|
+
if (powEngine) body.proofOfWork = powEngine.challenge(resource);
|
|
80
|
+
res.status(402).json(body);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Free rail: proof-of-work.
|
|
84
|
+
const powHeader = req.headers["x-pow-solution"];
|
|
85
|
+
if (powEngine && powHeader) {
|
|
86
|
+
const r = powEngine.verify(powHeader, resource);
|
|
87
|
+
if (r.ok) { res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
88
|
+
res.setHeader("X-Pow-Error", r.reason);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Paid rail: x402 (USDC). Settlement verification is operator-supplied so
|
|
92
|
+
// we reuse the standard, audited x402 stack rather than reinvent it.
|
|
93
|
+
const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
|
|
94
|
+
if (payTo && typeof verifyX402 === "function" && payHeader) {
|
|
95
|
+
return Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource }))
|
|
96
|
+
.then((ok) => {
|
|
97
|
+
if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
98
|
+
send402();
|
|
99
|
+
})
|
|
100
|
+
.catch(() => { res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return send402();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
tollbooth.shouldCharge = shouldCharge;
|
|
107
|
+
tollbooth.pow = powEngine;
|
|
108
|
+
return tollbooth;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Minimal reverse proxy: forward a request to `upstream`, streaming-safe-ish. */
|
|
112
|
+
export function createProxy(upstream) {
|
|
113
|
+
return async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const target = new URL(req.originalUrl || req.url, upstream);
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
118
|
+
const lk = k.toLowerCase();
|
|
119
|
+
if (lk === "host" || lk === "connection" || lk === "content-length") continue;
|
|
120
|
+
headers[k] = v;
|
|
121
|
+
}
|
|
122
|
+
const method = req.method;
|
|
123
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
124
|
+
const up = await fetch(target, { method, headers, body, redirect: "manual" });
|
|
125
|
+
res.status(up.status);
|
|
126
|
+
up.headers.forEach((val, key) => {
|
|
127
|
+
const lk = key.toLowerCase();
|
|
128
|
+
// fetch already decoded the body; drop hop-by-hop / length/encoding headers.
|
|
129
|
+
if (["content-encoding", "content-length", "transfer-encoding", "connection"].includes(lk)) return;
|
|
130
|
+
res.setHeader(key, val);
|
|
131
|
+
});
|
|
132
|
+
res.send(Buffer.from(await up.arrayBuffer()));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
res.status(502).json({ error: `tollbooth proxy failed: ${e.message}` });
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readBody(req, cap = 10 * 1024 * 1024) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let n = 0;
|
|
143
|
+
req.on("data", (c) => {
|
|
144
|
+
n += c.length;
|
|
145
|
+
if (n > cap) { reject(new Error("request body too large")); req.destroy(); return; }
|
|
146
|
+
chunks.push(c);
|
|
147
|
+
});
|
|
148
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
149
|
+
req.on("error", reject);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function startCli() {
|
|
154
|
+
const upstream = process.env.TOLLBOOTH_UPSTREAM;
|
|
155
|
+
const port = Number(process.env.PORT) || 4021;
|
|
156
|
+
const { default: express } = await import("express");
|
|
157
|
+
const app = express();
|
|
158
|
+
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
|
159
|
+
app.use(gate);
|
|
160
|
+
if (upstream) {
|
|
161
|
+
app.use(createProxy(upstream));
|
|
162
|
+
} else {
|
|
163
|
+
app.use((_req, res) => res.json({ ok: true, note: "Bare tollbooth gate (no TOLLBOOTH_UPSTREAM set). Clients that reach here paid or solved a proof-of-work." }));
|
|
164
|
+
}
|
|
165
|
+
app.listen(port, () => {
|
|
166
|
+
const rails = `${gate.pow ? "proof-of-work" : ""}${process.env.TOLLBOOTH_PAYTO ? (gate.pow ? " + x402(USDC)" : "x402(USDC)") : ""}`;
|
|
167
|
+
console.log(`agent402-tollbooth listening on :${port} — charging AI bots via ${rails || "proof-of-work"}`);
|
|
168
|
+
if (upstream) console.log(` proxying → ${upstream}`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) startCli();
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent402-tollbooth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy — no Cloudflare, no Stripe, no signup.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agent402-tollbooth": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"bots.js",
|
|
16
|
+
"pow.js",
|
|
17
|
+
"example.js",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node test.js",
|
|
25
|
+
"example": "node example.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"express": "^5.0.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"x402",
|
|
32
|
+
"pay-per-crawl",
|
|
33
|
+
"ai-bots",
|
|
34
|
+
"ai-crawler",
|
|
35
|
+
"bot-monetization",
|
|
36
|
+
"http-402",
|
|
37
|
+
"402-payment-required",
|
|
38
|
+
"proof-of-work",
|
|
39
|
+
"usdc",
|
|
40
|
+
"reverse-proxy",
|
|
41
|
+
"express-middleware",
|
|
42
|
+
"self-hosted",
|
|
43
|
+
"mcp",
|
|
44
|
+
"ai-agents"
|
|
45
|
+
],
|
|
46
|
+
"author": "Mikey Petrillo (https://github.com/MikeyPetrillo)",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://github.com/MikeyPetrillo/Agent402.git",
|
|
51
|
+
"directory": "tollbooth"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/MikeyPetrillo/Agent402/tree/main/tollbooth"
|
|
54
|
+
}
|
package/pow.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Self-contained proof-of-work for the tollbooth: a no-wallet payment rail.
|
|
2
|
+
// A client without USDC proves it spent real CPU instead — the same scheme
|
|
3
|
+
// Agent402 uses, packaged standalone so the tollbooth has zero crypto deps.
|
|
4
|
+
//
|
|
5
|
+
// Tokens are HMAC-signed by this process (stateless to issue), bound to the
|
|
6
|
+
// exact resource they were minted for, expiry-checked, and single-use (a solved
|
|
7
|
+
// token is remembered until it expires). Solving costs the caller CPU; issuing
|
|
8
|
+
// and verifying cost us one hash + one HMAC.
|
|
9
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/** Count leading zero bits of a buffer (the proof-of-work difficulty metric). */
|
|
14
|
+
export function leadingZeroBits(buf) {
|
|
15
|
+
let bits = 0;
|
|
16
|
+
for (const b of buf) {
|
|
17
|
+
if (b === 0) { bits += 8; continue; }
|
|
18
|
+
bits += Math.clz32(b) - 24;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return bits;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createPow({
|
|
25
|
+
secret = process.env.TOLLBOOTH_SECRET || randomBytes(32).toString("hex"),
|
|
26
|
+
difficulty = Number(process.env.TOLLBOOTH_POW_BITS) || 18,
|
|
27
|
+
ttlMs = TTL_MS,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const used = new Map(); // token -> expiry(ms)
|
|
30
|
+
const sweep = setInterval(() => {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [t, exp] of used) if (exp < now) used.delete(t);
|
|
33
|
+
}, 60_000);
|
|
34
|
+
sweep.unref?.();
|
|
35
|
+
|
|
36
|
+
const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
37
|
+
|
|
38
|
+
/** Mint a signed, resource-scoped challenge. */
|
|
39
|
+
function challenge(resource) {
|
|
40
|
+
const chal = randomBytes(16).toString("hex");
|
|
41
|
+
const exp = Date.now() + ttlMs;
|
|
42
|
+
const payload = `${chal}.${exp}.${difficulty}.${resource}`;
|
|
43
|
+
const token = `${payload}.${sign(payload)}`;
|
|
44
|
+
return {
|
|
45
|
+
algorithm: "sha256",
|
|
46
|
+
challenge: chal,
|
|
47
|
+
difficulty,
|
|
48
|
+
expires: exp,
|
|
49
|
+
token,
|
|
50
|
+
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${difficulty} leading zero bits, then resend the request with header X-Pow-Solution: ${token}:<nonce>`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Verify a "<token>:<nonce>" solution for a given resource. */
|
|
55
|
+
function verify(headerValue, resource) {
|
|
56
|
+
if (!headerValue || typeof headerValue !== "string") return { ok: false, reason: "missing solution" };
|
|
57
|
+
const cut = headerValue.lastIndexOf(":");
|
|
58
|
+
if (cut < 0) return { ok: false, reason: "malformed solution" };
|
|
59
|
+
const token = headerValue.slice(0, cut);
|
|
60
|
+
const nonce = headerValue.slice(cut + 1);
|
|
61
|
+
const parts = token.split(".");
|
|
62
|
+
if (parts.length !== 5) return { ok: false, reason: "malformed token" };
|
|
63
|
+
const [chal, expStr, diffStr, res, sig] = parts;
|
|
64
|
+
const expected = sign(`${chal}.${expStr}.${diffStr}.${res}`);
|
|
65
|
+
const a = Buffer.from(sig);
|
|
66
|
+
const b = Buffer.from(expected);
|
|
67
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "bad signature" };
|
|
68
|
+
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
69
|
+
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
70
|
+
if (used.has(token)) return { ok: false, reason: "already used" };
|
|
71
|
+
const hash = createHash("sha256").update(`${chal}:${nonce}`).digest();
|
|
72
|
+
if (leadingZeroBits(hash) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
73
|
+
used.set(token, Number(expStr));
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { challenge, verify, difficulty };
|
|
78
|
+
}
|