agent402-tollbooth 0.1.1 → 0.1.3
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 +53 -2
- package/edge.js +156 -0
- package/index.js +45 -14
- package/package.json +7 -2
- package/pow.js +5 -3
- package/worker.js +58 -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,
|
|
@@ -135,9 +170,25 @@ Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are intentionally
|
|
|
135
170
|
or take full control with a `charge(req)` predicate (e.g. charge anything
|
|
136
171
|
without a browser-like `Accept` header, or charge everyone on `/api/*`).
|
|
137
172
|
|
|
173
|
+
## Production checklist (read this)
|
|
174
|
+
|
|
175
|
+
- **Set a stable `TOLLBOOTH_SECRET`.** Required for any multi-process/clustered
|
|
176
|
+
Node deploy and for all edge deploys — without it, proof-of-work tokens use a
|
|
177
|
+
random per-process secret and are rejected across restarts/workers/isolates.
|
|
178
|
+
- **For serverless/edge, supply a durable replay `store`** (e.g. bind a Cloudflare
|
|
179
|
+
KV namespace as `TOLLBOOTH_KV`). The in-memory default is per-isolate, so a
|
|
180
|
+
solved token could be reused across isolates within its TTL. The Worker entry
|
|
181
|
+
warns when no KV is bound.
|
|
182
|
+
- **The reverse proxy pins the host** to your configured upstream (a client can't
|
|
183
|
+
redirect it elsewhere) and **strips client-forged trust/forwarding headers**
|
|
184
|
+
(`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
|
|
185
|
+
- **UA matching is the default, not a security boundary** — a bot can forge a
|
|
186
|
+
human UA to get the *same free access a human gets* (it gains nothing more). If
|
|
187
|
+
you need to charge everyone, pass `charge: () => true`.
|
|
188
|
+
|
|
138
189
|
## Notes
|
|
139
190
|
|
|
140
191
|
- Proof-of-work tokens are HMAC-signed, expiry-checked, single-use, and bound to
|
|
141
|
-
the exact resource — a solution for
|
|
142
|
-
|
|
192
|
+
the exact resource (path + query, dots and all) — a solution for one URL can't
|
|
193
|
+
be replayed or reused on another.
|
|
143
194
|
- MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
package/edge.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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 MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
|
|
47
|
+
const used = store || {
|
|
48
|
+
has: async (k) => { const e = mem.get(k); if (e && e < Date.now()) { mem.delete(k); return false; } return mem.has(k); },
|
|
49
|
+
add: async (k, exp) => {
|
|
50
|
+
if (mem.size >= MAX) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
|
|
53
|
+
if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
|
|
54
|
+
}
|
|
55
|
+
mem.set(k, exp);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function challenge(resource) {
|
|
60
|
+
const rnd = new Uint8Array(16);
|
|
61
|
+
crypto.getRandomValues(rnd);
|
|
62
|
+
const chal = toHex(rnd);
|
|
63
|
+
const exp = Date.now() + ttlMs;
|
|
64
|
+
const payload = `${chal}.${exp}.${difficulty}.${resource}`;
|
|
65
|
+
const token = `${payload}.${await hmac(secret, payload)}`;
|
|
66
|
+
return {
|
|
67
|
+
algorithm: "sha256",
|
|
68
|
+
challenge: chal,
|
|
69
|
+
difficulty,
|
|
70
|
+
expires: exp,
|
|
71
|
+
token,
|
|
72
|
+
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${difficulty} leading zero bits, then resend with header X-Pow-Solution: ${token}:<nonce>`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function verify(headerValue, resource) {
|
|
77
|
+
if (!headerValue || typeof headerValue !== "string") return { ok: false, reason: "missing solution" };
|
|
78
|
+
const cut = headerValue.lastIndexOf(":");
|
|
79
|
+
if (cut < 0) return { ok: false, reason: "malformed solution" };
|
|
80
|
+
const token = headerValue.slice(0, cut);
|
|
81
|
+
const nonce = headerValue.slice(cut + 1);
|
|
82
|
+
const parts = token.split(".");
|
|
83
|
+
if (parts.length < 5) return { ok: false, reason: "malformed token" };
|
|
84
|
+
const sig = parts.pop();
|
|
85
|
+
const [chal, expStr, diffStr] = parts;
|
|
86
|
+
const res = parts.slice(3).join("."); // resource may itself contain dots (e.g. /post.html?v=1.2)
|
|
87
|
+
const expected = await hmac(secret, parts.join("."));
|
|
88
|
+
if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
|
|
89
|
+
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
90
|
+
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
91
|
+
if (await used.has(token)) return { ok: false, reason: "already used" };
|
|
92
|
+
const h = await sha256(`${chal}:${nonce}`);
|
|
93
|
+
if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
94
|
+
await used.add(token, Number(expStr));
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { challenge, verify, difficulty };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create the edge gate. Returns `async (request) => Response | null`:
|
|
103
|
+
* a 402 `Response` when the client must pay, or `null` to allow the request
|
|
104
|
+
* through (proxy it, or `NextResponse.next()`).
|
|
105
|
+
*/
|
|
106
|
+
export function createEdgeTollbooth(config = {}) {
|
|
107
|
+
const {
|
|
108
|
+
price = "$0.001",
|
|
109
|
+
payTo = null,
|
|
110
|
+
network = "base",
|
|
111
|
+
asset = "USDC",
|
|
112
|
+
pow = true,
|
|
113
|
+
powDifficulty,
|
|
114
|
+
secret,
|
|
115
|
+
store,
|
|
116
|
+
botUserAgents = AI_BOTS,
|
|
117
|
+
charge,
|
|
118
|
+
free,
|
|
119
|
+
resourceBaseUrl = "",
|
|
120
|
+
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.",
|
|
121
|
+
} = config;
|
|
122
|
+
|
|
123
|
+
const isBot = makeBotMatcher(botUserAgents);
|
|
124
|
+
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
125
|
+
|
|
126
|
+
const shouldCharge = (request) => {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof free === "function" && free(request)) return false;
|
|
129
|
+
if (typeof charge === "function") return Boolean(charge(request));
|
|
130
|
+
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
131
|
+
return isBot(request.headers.get("user-agent") || "");
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return async function gate(request) {
|
|
135
|
+
if (!shouldCharge(request)) return null;
|
|
136
|
+
const u = new URL(request.url);
|
|
137
|
+
const resource = (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + u.pathname + u.search;
|
|
138
|
+
|
|
139
|
+
const sol = request.headers.get("x-pow-solution");
|
|
140
|
+
if (powEngine && sol) {
|
|
141
|
+
const r = await powEngine.verify(sol, resource);
|
|
142
|
+
if (r.ok) return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const body = {
|
|
146
|
+
error: "Payment Required",
|
|
147
|
+
message,
|
|
148
|
+
accepts: payTo ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
|
|
149
|
+
};
|
|
150
|
+
if (powEngine) body.proofOfWork = await powEngine.challenge(resource);
|
|
151
|
+
return new Response(JSON.stringify(body), {
|
|
152
|
+
status: 402,
|
|
153
|
+
headers: { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" },
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
}
|
package/index.js
CHANGED
|
@@ -13,12 +13,22 @@
|
|
|
13
13
|
// accept USDC, set `payTo` and supply `verifyX402` (wire it to the standard
|
|
14
14
|
// x402 server middleware / your facilitator — see README).
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { Readable } from "node:stream";
|
|
16
17
|
import { createPow } from "./pow.js";
|
|
17
18
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
18
19
|
|
|
19
20
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
20
21
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
21
22
|
|
|
23
|
+
const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
|
|
24
|
+
// Headers a client must never be able to forge through the proxy: the gate's own
|
|
25
|
+
// trust signals and forwarding/hop-by-hop headers.
|
|
26
|
+
const STRIP_INBOUND = new Set([
|
|
27
|
+
"host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
28
|
+
"te", "trailer", "transfer-encoding", "upgrade", "content-length",
|
|
29
|
+
"x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded",
|
|
30
|
+
]);
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Create the tollbooth Express middleware.
|
|
24
34
|
* @param {object} [config]
|
|
@@ -56,12 +66,20 @@ export function createTollbooth(config = {}) {
|
|
|
56
66
|
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
57
67
|
|
|
58
68
|
const shouldCharge = (req) => {
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
try {
|
|
70
|
+
if (typeof free === "function" && free(req)) return false;
|
|
71
|
+
if (typeof charge === "function") return Boolean(charge(req));
|
|
72
|
+
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
61
73
|
return isBot(req.headers["user-agent"] || "");
|
|
62
74
|
};
|
|
63
|
-
const resourceOf = (req) =>
|
|
64
|
-
|
|
75
|
+
const resourceOf = (req) => {
|
|
76
|
+
// Canonicalize to path+search (matches the edge impl) so the PoW binding is
|
|
77
|
+
// stable and not confusable via a raw/abnormal request target.
|
|
78
|
+
const raw = req.originalUrl || req.url || "/";
|
|
79
|
+
let pathAndSearch = raw;
|
|
80
|
+
try { const u = new URL(raw, "http://internal.invalid"); pathAndSearch = u.pathname + u.search; } catch { /* keep raw */ }
|
|
81
|
+
return (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + pathAndSearch;
|
|
82
|
+
};
|
|
65
83
|
|
|
66
84
|
function tollbooth(req, res, next) {
|
|
67
85
|
if (!shouldCharge(req)) return next();
|
|
@@ -92,7 +110,9 @@ export function createTollbooth(config = {}) {
|
|
|
92
110
|
// we reuse the standard, audited x402 stack rather than reinvent it.
|
|
93
111
|
const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
|
|
94
112
|
if (payTo && typeof verifyX402 === "function" && payHeader) {
|
|
95
|
-
|
|
113
|
+
// Bound verification time so a slow/hung verifier can't exhaust resources.
|
|
114
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
|
|
115
|
+
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
|
|
96
116
|
.then((ok) => {
|
|
97
117
|
if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
98
118
|
send402();
|
|
@@ -108,19 +128,25 @@ export function createTollbooth(config = {}) {
|
|
|
108
128
|
return tollbooth;
|
|
109
129
|
}
|
|
110
130
|
|
|
111
|
-
/** Minimal reverse proxy: forward
|
|
112
|
-
|
|
131
|
+
/** Minimal reverse proxy: forward to `upstream` with the host PINNED (no SSRF via
|
|
132
|
+
* the request target), client trust/forwarding headers stripped, and the
|
|
133
|
+
* response STREAMED (no unbounded buffering). */
|
|
134
|
+
export function createProxy(upstream, { maxBody = 10 * 1024 * 1024 } = {}) {
|
|
135
|
+
const base = new URL(upstream);
|
|
113
136
|
return async (req, res) => {
|
|
114
137
|
try {
|
|
115
|
-
|
|
138
|
+
// Take ONLY the path+query from the (possibly hostile) target; the
|
|
139
|
+
// authority is always the operator's upstream — protocol-relative or
|
|
140
|
+
// absolute-form targets cannot redirect us to another host.
|
|
141
|
+
const reqUrl = new URL(req.originalUrl || req.url || "/", base);
|
|
142
|
+
const target = new URL(reqUrl.pathname + reqUrl.search, base);
|
|
116
143
|
const headers = {};
|
|
117
144
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
118
|
-
|
|
119
|
-
if (lk === "host" || lk === "connection" || lk === "content-length") continue;
|
|
120
|
-
headers[k] = v;
|
|
145
|
+
if (!STRIP_INBOUND.has(k.toLowerCase())) headers[k] = v;
|
|
121
146
|
}
|
|
147
|
+
headers["x-forwarded-for"] = req.socket?.remoteAddress || ""; // set by us, not the client
|
|
122
148
|
const method = req.method;
|
|
123
|
-
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
149
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req, maxBody);
|
|
124
150
|
const up = await fetch(target, { method, headers, body, redirect: "manual" });
|
|
125
151
|
res.status(up.status);
|
|
126
152
|
up.headers.forEach((val, key) => {
|
|
@@ -129,9 +155,11 @@ export function createProxy(upstream) {
|
|
|
129
155
|
if (["content-encoding", "content-length", "transfer-encoding", "connection"].includes(lk)) return;
|
|
130
156
|
res.setHeader(key, val);
|
|
131
157
|
});
|
|
132
|
-
|
|
158
|
+
if (up.body) Readable.fromWeb(up.body).pipe(res);
|
|
159
|
+
else res.end();
|
|
133
160
|
} catch (e) {
|
|
134
|
-
res.status(502).json({ error: `tollbooth proxy failed: ${e.message}` });
|
|
161
|
+
if (!res.headersSent) res.status(502).json({ error: `tollbooth proxy failed: ${e.message}` });
|
|
162
|
+
else res.end();
|
|
135
163
|
}
|
|
136
164
|
};
|
|
137
165
|
}
|
|
@@ -153,6 +181,9 @@ function readBody(req, cap = 10 * 1024 * 1024) {
|
|
|
153
181
|
async function startCli() {
|
|
154
182
|
const upstream = process.env.TOLLBOOTH_UPSTREAM;
|
|
155
183
|
const port = Number(process.env.PORT) || 4021;
|
|
184
|
+
if (!process.env.TOLLBOOTH_SECRET) {
|
|
185
|
+
console.warn("⚠ TOLLBOOTH_SECRET not set — proof-of-work tokens use a random per-process secret: they won't survive a restart and will be rejected across multiple workers/instances. Set a stable TOLLBOOTH_SECRET in production.");
|
|
186
|
+
}
|
|
156
187
|
const { default: express } = await import("express");
|
|
157
188
|
const app = express();
|
|
158
189
|
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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/pow.js
CHANGED
|
@@ -59,9 +59,11 @@ export function createPow({
|
|
|
59
59
|
const token = headerValue.slice(0, cut);
|
|
60
60
|
const nonce = headerValue.slice(cut + 1);
|
|
61
61
|
const parts = token.split(".");
|
|
62
|
-
if (parts.length
|
|
63
|
-
const
|
|
64
|
-
const
|
|
62
|
+
if (parts.length < 5) return { ok: false, reason: "malformed token" };
|
|
63
|
+
const sig = parts.pop();
|
|
64
|
+
const [chal, expStr, diffStr] = parts;
|
|
65
|
+
const res = parts.slice(3).join("."); // resource may itself contain dots (e.g. /post.html?v=1.2)
|
|
66
|
+
const expected = sign(parts.join("."));
|
|
65
67
|
const a = Buffer.from(sig);
|
|
66
68
|
const b = Buffer.from(expected);
|
|
67
69
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "bad signature" };
|
package/worker.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
if (!env.TOLLBOOTH_KV) {
|
|
26
|
+
// No durable store → replay protection is per-isolate only; a solved token
|
|
27
|
+
// can be reused across isolates within its TTL. Bind a KV namespace for prod.
|
|
28
|
+
console.warn("agent402-tollbooth: no TOLLBOOTH_KV bound — proof-of-work replay protection is per-isolate only. Bind a KV namespace for production.");
|
|
29
|
+
}
|
|
30
|
+
const gate = createEdgeTollbooth({
|
|
31
|
+
secret: env.TOLLBOOTH_SECRET,
|
|
32
|
+
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
33
|
+
payTo: env.TOLLBOOTH_PAYTO || null,
|
|
34
|
+
network: env.TOLLBOOTH_NETWORK || "base",
|
|
35
|
+
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
36
|
+
store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const blocked = await gate(request);
|
|
40
|
+
if (blocked) return blocked;
|
|
41
|
+
|
|
42
|
+
// Allowed → proxy to the origin.
|
|
43
|
+
const upstream = env.TOLLBOOTH_UPSTREAM;
|
|
44
|
+
if (!upstream) return new Response("Tollbooth: set TOLLBOOTH_UPSTREAM to your origin", { status: 500 });
|
|
45
|
+
const target = new URL(request.url);
|
|
46
|
+
const origin = new URL(upstream);
|
|
47
|
+
target.protocol = origin.protocol;
|
|
48
|
+
target.hostname = origin.hostname;
|
|
49
|
+
target.port = origin.port;
|
|
50
|
+
// Strip client-forgeable trust/forwarding headers before forwarding to origin.
|
|
51
|
+
const headers = new Headers(request.headers);
|
|
52
|
+
for (const h of ["x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded"]) headers.delete(h);
|
|
53
|
+
headers.set("x-forwarded-for", request.headers.get("cf-connecting-ip") || "");
|
|
54
|
+
const init = { method: request.method, headers, redirect: "manual" };
|
|
55
|
+
if (request.method !== "GET" && request.method !== "HEAD") init.body = request.body;
|
|
56
|
+
return fetch(target.toString(), init);
|
|
57
|
+
},
|
|
58
|
+
};
|