agent402-tollbooth 0.1.1 → 0.1.2
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 +35 -0
- package/edge.js +144 -0
- package/package.json +7 -2
- package/worker.js +47 -0
package/README.md
CHANGED
|
@@ -86,6 +86,41 @@ TOLLBOOTH_PAYTO=0xYourWallet \
|
|
|
86
86
|
npx agent402-tollbooth # listens on :4021, proxies humans free, charges bots
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
## Run on the edge (Cloudflare Workers, Next.js, Deno, Bun)
|
|
90
|
+
|
|
91
|
+
The same gate is also built on the Web Crypto + Fetch APIs (`edge.js`), so it runs
|
|
92
|
+
anywhere — no Node required. The gate returns a `402 Response` when the client
|
|
93
|
+
must pay, or `null` to let it through.
|
|
94
|
+
|
|
95
|
+
**Cloudflare Workers** (poetic: the open pay-per-crawl, on the incumbent's own platform). `wrangler.toml`:
|
|
96
|
+
|
|
97
|
+
```toml
|
|
98
|
+
name = "tollbooth"
|
|
99
|
+
main = "node_modules/agent402-tollbooth/worker.js"
|
|
100
|
+
[vars]
|
|
101
|
+
TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
|
|
102
|
+
TOLLBOOTH_PAYTO = "0xYourWallet" # optional: advertise a USDC x402 quote
|
|
103
|
+
# wrangler secret put TOLLBOOTH_SECRET
|
|
104
|
+
# optional single-use replay store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Next.js middleware** (`middleware.ts`):
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { createEdgeTollbooth } from "agent402-tollbooth/edge";
|
|
111
|
+
const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET! });
|
|
112
|
+
|
|
113
|
+
export async function middleware(req: Request) {
|
|
114
|
+
return (await gate(req)) ?? NextResponse.next();
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Any Fetch-API runtime** (Deno, Bun, custom): `const gate = createEdgeTollbooth({ secret }); const blocked = await gate(request); return blocked ?? fetch(request);`
|
|
119
|
+
|
|
120
|
+
> On the edge, pass a stable `secret` (PoW tokens are HMAC-signed). For
|
|
121
|
+
> single-use replay protection across stateless invocations, supply a `store`
|
|
122
|
+
> (e.g. a Cloudflare KV wrapper — the Worker entry wires this for you).
|
|
123
|
+
|
|
89
124
|
## Accepting USDC (x402)
|
|
90
125
|
|
|
91
126
|
The proof-of-work rail works with **zero config**. To also settle real USDC,
|
package/edge.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Edge / runtime-agnostic tollbooth — the same pay-per-crawl gate, built on the
|
|
2
|
+
// Web Crypto + Fetch APIs so it runs anywhere: Cloudflare Workers, Next.js
|
|
3
|
+
// middleware (edge), Deno, Bun, and Node 20+. No node:* imports.
|
|
4
|
+
//
|
|
5
|
+
// const gate = createEdgeTollbooth({ secret: env.TOLLBOOTH_SECRET });
|
|
6
|
+
// const blocked = await gate(request); // Response(402) if must pay, else null
|
|
7
|
+
//
|
|
8
|
+
// See worker.js for a Cloudflare Worker entry, and the README for Next.js.
|
|
9
|
+
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
10
|
+
|
|
11
|
+
const te = new TextEncoder();
|
|
12
|
+
|
|
13
|
+
function b64url(buf) {
|
|
14
|
+
const bytes = new Uint8Array(buf);
|
|
15
|
+
let s = "";
|
|
16
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
17
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
const toHex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
20
|
+
function leadingZeroBits(bytes) {
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (const b of bytes) { if (b === 0) { n += 8; continue; } n += Math.clz32(b) - 24; break; }
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
async function hmac(secret, payload) {
|
|
26
|
+
const key = await crypto.subtle.importKey("raw", te.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
27
|
+
return b64url(await crypto.subtle.sign("HMAC", key, te.encode(payload)));
|
|
28
|
+
}
|
|
29
|
+
const sha256 = async (str) => new Uint8Array(await crypto.subtle.digest("SHA-256", te.encode(str)));
|
|
30
|
+
function constEq(a, b) {
|
|
31
|
+
if (a.length !== b.length) return false;
|
|
32
|
+
let r = 0;
|
|
33
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
34
|
+
return r === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
|
|
39
|
+
* random per-process secret across invocations). `store` is an optional
|
|
40
|
+
* single-use replay backend ({ has(token), add(token, expMs) }, both async) —
|
|
41
|
+
* e.g. a Cloudflare KV wrapper; defaults to in-isolate memory.
|
|
42
|
+
*/
|
|
43
|
+
export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
|
|
44
|
+
if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
|
|
45
|
+
const mem = new Map();
|
|
46
|
+
const used = store || {
|
|
47
|
+
has: async (k) => { const e = mem.get(k); if (e && e < Date.now()) { mem.delete(k); return false; } return mem.has(k); },
|
|
48
|
+
add: async (k, exp) => { mem.set(k, exp); },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
async function challenge(resource) {
|
|
52
|
+
const rnd = new Uint8Array(16);
|
|
53
|
+
crypto.getRandomValues(rnd);
|
|
54
|
+
const chal = toHex(rnd);
|
|
55
|
+
const exp = Date.now() + ttlMs;
|
|
56
|
+
const payload = `${chal}.${exp}.${difficulty}.${resource}`;
|
|
57
|
+
const token = `${payload}.${await hmac(secret, payload)}`;
|
|
58
|
+
return {
|
|
59
|
+
algorithm: "sha256",
|
|
60
|
+
challenge: chal,
|
|
61
|
+
difficulty,
|
|
62
|
+
expires: exp,
|
|
63
|
+
token,
|
|
64
|
+
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${difficulty} leading zero bits, then resend with header X-Pow-Solution: ${token}:<nonce>`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function verify(headerValue, resource) {
|
|
69
|
+
if (!headerValue || typeof headerValue !== "string") return { ok: false, reason: "missing solution" };
|
|
70
|
+
const cut = headerValue.lastIndexOf(":");
|
|
71
|
+
if (cut < 0) return { ok: false, reason: "malformed solution" };
|
|
72
|
+
const token = headerValue.slice(0, cut);
|
|
73
|
+
const nonce = headerValue.slice(cut + 1);
|
|
74
|
+
const parts = token.split(".");
|
|
75
|
+
if (parts.length !== 5) return { ok: false, reason: "malformed token" };
|
|
76
|
+
const [chal, expStr, diffStr, res, sig] = parts;
|
|
77
|
+
const expected = await hmac(secret, `${chal}.${expStr}.${diffStr}.${res}`);
|
|
78
|
+
if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
|
|
79
|
+
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
80
|
+
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
81
|
+
if (await used.has(token)) return { ok: false, reason: "already used" };
|
|
82
|
+
const h = await sha256(`${chal}:${nonce}`);
|
|
83
|
+
if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
84
|
+
await used.add(token, Number(expStr));
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { challenge, verify, difficulty };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create the edge gate. Returns `async (request) => Response | null`:
|
|
93
|
+
* a 402 `Response` when the client must pay, or `null` to allow the request
|
|
94
|
+
* through (proxy it, or `NextResponse.next()`).
|
|
95
|
+
*/
|
|
96
|
+
export function createEdgeTollbooth(config = {}) {
|
|
97
|
+
const {
|
|
98
|
+
price = "$0.001",
|
|
99
|
+
payTo = null,
|
|
100
|
+
network = "base",
|
|
101
|
+
asset = "USDC",
|
|
102
|
+
pow = true,
|
|
103
|
+
powDifficulty,
|
|
104
|
+
secret,
|
|
105
|
+
store,
|
|
106
|
+
botUserAgents = AI_BOTS,
|
|
107
|
+
charge,
|
|
108
|
+
free,
|
|
109
|
+
resourceBaseUrl = "",
|
|
110
|
+
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.",
|
|
111
|
+
} = config;
|
|
112
|
+
|
|
113
|
+
const isBot = makeBotMatcher(botUserAgents);
|
|
114
|
+
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
115
|
+
|
|
116
|
+
const shouldCharge = (request) => {
|
|
117
|
+
if (typeof free === "function" && free(request)) return false;
|
|
118
|
+
if (typeof charge === "function") return Boolean(charge(request));
|
|
119
|
+
return isBot(request.headers.get("user-agent") || "");
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return async function gate(request) {
|
|
123
|
+
if (!shouldCharge(request)) return null;
|
|
124
|
+
const u = new URL(request.url);
|
|
125
|
+
const resource = (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + u.pathname + u.search;
|
|
126
|
+
|
|
127
|
+
const sol = request.headers.get("x-pow-solution");
|
|
128
|
+
if (powEngine && sol) {
|
|
129
|
+
const r = await powEngine.verify(sol, resource);
|
|
130
|
+
if (r.ok) return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const body = {
|
|
134
|
+
error: "Payment Required",
|
|
135
|
+
message,
|
|
136
|
+
accepts: payTo ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
|
|
137
|
+
};
|
|
138
|
+
if (powEngine) body.proofOfWork = await powEngine.challenge(resource);
|
|
139
|
+
return new Response(JSON.stringify(body), {
|
|
140
|
+
status: 402,
|
|
141
|
+
headers: { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" },
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"index.js",
|
|
15
|
+
"edge.js",
|
|
16
|
+
"worker.js",
|
|
15
17
|
"bots.js",
|
|
16
18
|
"pow.js",
|
|
17
19
|
"example.js",
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"node": ">=20"
|
|
23
25
|
},
|
|
24
26
|
"scripts": {
|
|
25
|
-
"test": "node test.js",
|
|
27
|
+
"test": "node test.js && node edge.test.js",
|
|
26
28
|
"demo": "node demo.js",
|
|
27
29
|
"example": "node example.js"
|
|
28
30
|
},
|
|
@@ -41,6 +43,9 @@
|
|
|
41
43
|
"usdc",
|
|
42
44
|
"reverse-proxy",
|
|
43
45
|
"express-middleware",
|
|
46
|
+
"cloudflare-workers",
|
|
47
|
+
"nextjs",
|
|
48
|
+
"edge",
|
|
44
49
|
"self-hosted",
|
|
45
50
|
"mcp",
|
|
46
51
|
"ai-agents"
|
package/worker.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Cloudflare Worker entry for the tollbooth — open-source pay-per-crawl on the
|
|
2
|
+
// edge. Deploy in front of your origin: humans pass through, AI crawlers pay
|
|
3
|
+
// (USDC via x402, or free proof-of-work).
|
|
4
|
+
//
|
|
5
|
+
// wrangler.toml:
|
|
6
|
+
// name = "tollbooth"
|
|
7
|
+
// main = "node_modules/agent402-tollbooth/worker.js"
|
|
8
|
+
// [vars]
|
|
9
|
+
// TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
|
|
10
|
+
// TOLLBOOTH_PAYTO = "0xYourWallet" # optional (advertises USDC quote)
|
|
11
|
+
// # secret (required): wrangler secret put TOLLBOOTH_SECRET
|
|
12
|
+
// # optional single-use store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
|
|
13
|
+
import { createEdgeTollbooth } from "./edge.js";
|
|
14
|
+
|
|
15
|
+
const kvStore = (kv) => ({
|
|
16
|
+
has: async (k) => (await kv.get(k)) != null,
|
|
17
|
+
add: async (k, expMs) => kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
async fetch(request, env) {
|
|
22
|
+
if (!env.TOLLBOOTH_SECRET) {
|
|
23
|
+
return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
|
|
24
|
+
}
|
|
25
|
+
const gate = createEdgeTollbooth({
|
|
26
|
+
secret: env.TOLLBOOTH_SECRET,
|
|
27
|
+
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
28
|
+
payTo: env.TOLLBOOTH_PAYTO || null,
|
|
29
|
+
network: env.TOLLBOOTH_NETWORK || "base",
|
|
30
|
+
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
31
|
+
store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const blocked = await gate(request);
|
|
35
|
+
if (blocked) return blocked;
|
|
36
|
+
|
|
37
|
+
// Allowed → proxy to the origin.
|
|
38
|
+
const upstream = env.TOLLBOOTH_UPSTREAM;
|
|
39
|
+
if (!upstream) return new Response("Tollbooth: set TOLLBOOTH_UPSTREAM to your origin", { status: 500 });
|
|
40
|
+
const target = new URL(request.url);
|
|
41
|
+
const origin = new URL(upstream);
|
|
42
|
+
target.protocol = origin.protocol;
|
|
43
|
+
target.hostname = origin.hostname;
|
|
44
|
+
target.port = origin.port;
|
|
45
|
+
return fetch(new Request(target.toString(), request));
|
|
46
|
+
},
|
|
47
|
+
};
|