agent402-tollbooth 0.1.3 → 0.2.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 +57 -19
- package/dashboard.js +48 -0
- package/edge.js +25 -7
- package/index.js +52 -5
- package/package.json +3 -2
- package/pow.js +8 -5
- package/worker.js +8 -2
package/README.md
CHANGED
|
@@ -92,27 +92,41 @@ The same gate is also built on the Web Crypto + Fetch APIs (`edge.js`), so it ru
|
|
|
92
92
|
anywhere — no Node required. The gate returns a `402 Response` when the client
|
|
93
93
|
must pay, or `null` to let it through.
|
|
94
94
|
|
|
95
|
-
**
|
|
95
|
+
**Ready-to-deploy templates** (copy a folder, don't assemble from docs):
|
|
96
|
+
|
|
97
|
+
- **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/) — a ready
|
|
98
|
+
`wrangler.toml` + a 3-step deploy guide (the open pay-per-crawl, on the
|
|
99
|
+
incumbent's own platform).
|
|
100
|
+
- **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/) — a drop-in
|
|
101
|
+
`middleware.js` + a 3-step deploy guide.
|
|
102
|
+
- **Docker** → [`deploy/docker/`](deploy/docker/) — a `Dockerfile` +
|
|
103
|
+
`docker-compose.yml` to run the reverse proxy in front of any site with
|
|
104
|
+
`docker compose up -d` (includes the live `/__tollbooth` dashboard).
|
|
105
|
+
|
|
106
|
+
The short version of each:
|
|
96
107
|
|
|
97
108
|
```toml
|
|
109
|
+
# wrangler.toml (full template: deploy/cloudflare/wrangler.toml)
|
|
98
110
|
name = "tollbooth"
|
|
99
111
|
main = "node_modules/agent402-tollbooth/worker.js"
|
|
112
|
+
compatibility_date = "2026-01-01"
|
|
100
113
|
[vars]
|
|
101
114
|
TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
|
|
102
115
|
TOLLBOOTH_PAYTO = "0xYourWallet" # optional: advertise a USDC x402 quote
|
|
103
|
-
# wrangler secret put TOLLBOOTH_SECRET
|
|
116
|
+
# npx wrangler secret put TOLLBOOTH_SECRET
|
|
104
117
|
# optional single-use replay store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
|
|
105
118
|
```
|
|
106
119
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
```js
|
|
121
|
+
// middleware.js (full template: deploy/nextjs/middleware.js)
|
|
122
|
+
import { NextResponse } from "next/server";
|
|
110
123
|
import { createEdgeTollbooth } from "agent402-tollbooth/edge";
|
|
111
|
-
const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET
|
|
124
|
+
const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET });
|
|
112
125
|
|
|
113
|
-
export async function middleware(req
|
|
126
|
+
export async function middleware(req) {
|
|
114
127
|
return (await gate(req)) ?? NextResponse.next();
|
|
115
128
|
}
|
|
129
|
+
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
116
130
|
```
|
|
117
131
|
|
|
118
132
|
**Any Fetch-API runtime** (Deno, Bun, custom): `const gate = createEdgeTollbooth({ secret }); const blocked = await gate(request); return blocked ?? fetch(request);`
|
|
@@ -152,23 +166,46 @@ app.use(createTollbooth({
|
|
|
152
166
|
| `network` | `"base"` | x402 network |
|
|
153
167
|
| `pow` | `true` | Enable the free proof-of-work rail |
|
|
154
168
|
| `powDifficulty` | `18` | PoW difficulty in leading zero bits (~0.1–0.5s of CPU) |
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
169
|
+
| `mode` | `"bots"` | Who pays: `"bots"` (AI-crawler UAs) · `"all"` (everyone but `free()`) · `"strict"` (anything that isn't a real-browser request) |
|
|
170
|
+
| `adaptive` | `false` | Raise PoW difficulty as charged-request load climbs (anti-abuse under traffic spikes) |
|
|
171
|
+
| `maxDifficulty` | `base+6` | Ceiling for adaptive difficulty |
|
|
172
|
+
| `adaptivePerBit` | `300` | +1 difficulty bit per N charged requests/min |
|
|
173
|
+
| `botUserAgents` | `AI_BOTS` | User-agents to charge in `"bots"` mode |
|
|
174
|
+
| `charge(req)` | mode | Custom "should this client pay?" predicate (wins over `mode`) |
|
|
175
|
+
| `free(req)` | – | Custom force-allow predicate (wins over everything) |
|
|
158
176
|
| `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
|
|
159
177
|
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
160
178
|
|
|
161
179
|
Environment variables: `TOLLBOOTH_UPSTREAM`, `TOLLBOOTH_PAYTO`, `TOLLBOOTH_PRICE`,
|
|
162
|
-
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `
|
|
180
|
+
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_MODE`, `TOLLBOOTH_ADAPTIVE`,
|
|
181
|
+
`TOLLBOOTH_MAX_POW_BITS`, `TOLLBOOTH_ADAPTIVE_PER_BIT`, `TOLLBOOTH_SECRET`, `PORT`.
|
|
163
182
|
|
|
164
183
|
## How it decides who pays
|
|
165
184
|
|
|
166
|
-
By default it charges requests whose `User-Agent` matches a known
|
|
167
|
-
crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider,
|
|
168
|
-
Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are
|
|
169
|
-
**not** charged so your SEO indexing stays free.
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
By default (`mode: "bots"`) it charges requests whose `User-Agent` matches a known
|
|
186
|
+
**AI/LLM crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider,
|
|
187
|
+
Google-Extended, Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are
|
|
188
|
+
intentionally **not** charged so your SEO indexing stays free.
|
|
189
|
+
|
|
190
|
+
**Don't want to play whack-a-mole with bot detection?** That's the point of the
|
|
191
|
+
other modes — you stop trying to *identify* bots and instead make access *cost
|
|
192
|
+
something*:
|
|
193
|
+
- `mode: "all"` charges every client (except a `free()` match). A "more
|
|
194
|
+
sophisticated" bot gains nothing by disguising itself — everyone pays or solves
|
|
195
|
+
a proof-of-work.
|
|
196
|
+
- `mode: "strict"` charges anything that isn't a real-browser request (browser-like
|
|
197
|
+
UA **and** an HTML `Accept`), letting genuine human page-loads through free.
|
|
198
|
+
- `adaptive: true` makes proof-of-work **harder as load climbs**, so a high-volume
|
|
199
|
+
scraper pays escalating CPU per request regardless of how it looks — detection is
|
|
200
|
+
cat-and-mouse, economics isn't.
|
|
201
|
+
|
|
202
|
+
## Analytics
|
|
203
|
+
|
|
204
|
+
The middleware keeps aggregate counters (no per-request data): `gate.stats()`
|
|
205
|
+
returns `{ requests, freeAllowed, charged, powSolved, x402Paid, difficultyNow }`.
|
|
206
|
+
The reverse-proxy CLI exposes them as JSON at **`/__tollbooth/stats`** and as a
|
|
207
|
+
live **dashboard at `/__tollbooth`** — requests, how many were charged,
|
|
208
|
+
proof-of-work solves, USDC collected, and what share of your traffic is bots.
|
|
172
209
|
|
|
173
210
|
## Production checklist (read this)
|
|
174
211
|
|
|
@@ -183,8 +220,9 @@ without a browser-like `Accept` header, or charge everyone on `/api/*`).
|
|
|
183
220
|
redirect it elsewhere) and **strips client-forged trust/forwarding headers**
|
|
184
221
|
(`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
|
|
185
222
|
- **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).
|
|
187
|
-
|
|
223
|
+
human UA to get the *same free access a human gets* (it gains nothing more). To
|
|
224
|
+
stop relying on detection entirely, use `mode: "all"` / `mode: "strict"`, and
|
|
225
|
+
turn on `adaptive` so high-volume abuse pays escalating proof-of-work.
|
|
188
226
|
|
|
189
227
|
## Notes
|
|
190
228
|
|
package/dashboard.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A tiny self-contained operator dashboard for the tollbooth, served at
|
|
2
|
+
// /__tollbooth by the reverse-proxy CLI. It polls /__tollbooth/stats and shows
|
|
3
|
+
// how much of your traffic is bots and what the gate is collecting — the visual
|
|
4
|
+
// answer to "I had to upgrade my plan to serve bots I don't monetize."
|
|
5
|
+
// Pure function (no deps) so it's trivially testable.
|
|
6
|
+
export function dashboardHtml() {
|
|
7
|
+
return `<!doctype html>
|
|
8
|
+
<html lang="en"><head>
|
|
9
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<meta name="robots" content="noindex">
|
|
11
|
+
<title>agent402-tollbooth — live stats</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root{--bg:#0b0e14;--card:#131826;--text:#e6e9f0;--muted:#8b93a7;--accent:#4ade80;--line:#1e2638;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
14
|
+
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,sans-serif}
|
|
15
|
+
.wrap{max-width:880px;margin:0 auto;padding:40px 20px}
|
|
16
|
+
h1{font-size:1.4rem;display:flex;align-items:center;gap:10px}
|
|
17
|
+
.badge{font:700 14px/1 var(--mono);color:var(--accent);background:#000;border:1px solid #1f4a1d;border-radius:8px;padding:8px 10px}
|
|
18
|
+
.sub{color:var(--muted);margin:6px 0 24px;font-size:.9rem}
|
|
19
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px}
|
|
20
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px}
|
|
21
|
+
.k{color:var(--muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
|
|
22
|
+
.v{font:800 1.8rem/1.1 var(--mono);margin-top:8px}
|
|
23
|
+
.v.accent{color:var(--accent)}
|
|
24
|
+
footer{color:var(--muted);font-size:.8rem;margin-top:28px}
|
|
25
|
+
a{color:var(--accent)}
|
|
26
|
+
</style></head>
|
|
27
|
+
<body><div class="wrap">
|
|
28
|
+
<h1><span class="badge">402</span> agent402-tollbooth</h1>
|
|
29
|
+
<div class="sub">Live pay-per-crawl stats · refreshes every 5s · <span id="since"></span></div>
|
|
30
|
+
<div class="grid" id="grid"></div>
|
|
31
|
+
<footer>Bots are <b id="botpct">—</b> of requests. Aggregate counts only (no per-request data). Raw JSON: <a href="/__tollbooth/stats">/__tollbooth/stats</a>.</footer>
|
|
32
|
+
</div>
|
|
33
|
+
<script>
|
|
34
|
+
const cards=[["requests","Requests",false],["freeAllowed","Humans (free)",false],["charged","Charged (402)","accent"],["powSolved","Proof-of-work paid","accent"],["x402Paid","USDC paid (x402)","accent"],["difficultyNow","PoW difficulty",false]];
|
|
35
|
+
async function tick(){
|
|
36
|
+
try{
|
|
37
|
+
const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
|
|
38
|
+
document.getElementById("grid").innerHTML=cards.map(([k,label,acc])=>
|
|
39
|
+
'<div class="card"><div class="k">'+label+'</div><div class="v '+(acc||"")+'">'+(s[k]??0)+'</div></div>').join("");
|
|
40
|
+
const pct=s.requests?Math.round((s.charged/s.requests)*100):0;
|
|
41
|
+
document.getElementById("botpct").textContent=pct+"%";
|
|
42
|
+
if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
|
|
43
|
+
}catch(e){/* keep last values */}
|
|
44
|
+
}
|
|
45
|
+
tick();setInterval(tick,5000);
|
|
46
|
+
</script>
|
|
47
|
+
</body></html>`;
|
|
48
|
+
}
|
package/edge.js
CHANGED
|
@@ -37,22 +37,28 @@ function constEq(a, b) {
|
|
|
37
37
|
/**
|
|
38
38
|
* Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
|
|
39
39
|
* random per-process secret across invocations). `store` is an optional
|
|
40
|
-
* single-use replay backend
|
|
41
|
-
*
|
|
40
|
+
* single-use replay backend: { claim(token, expMs) => bool|Promise<bool> } that
|
|
41
|
+
* atomically returns true only the first time a token is seen — e.g. a Cloudflare
|
|
42
|
+
* KV wrapper; defaults to in-isolate memory.
|
|
42
43
|
*/
|
|
43
44
|
export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
|
|
44
45
|
if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
|
|
45
46
|
const mem = new Map();
|
|
46
47
|
const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
|
|
47
48
|
const used = store || {
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// Atomic single-use claim. The body is synchronous (no await), so two
|
|
50
|
+
// concurrent claims of the same token cannot both win within an isolate —
|
|
51
|
+
// closing the TOCTOU that a separate has()+add() would open.
|
|
52
|
+
claim: (k, exp) => {
|
|
53
|
+
const e = mem.get(k);
|
|
54
|
+
if (e && e >= Date.now()) return false; // a still-valid entry => already used
|
|
50
55
|
if (mem.size >= MAX) {
|
|
51
56
|
const now = Date.now();
|
|
52
57
|
for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
|
|
53
58
|
if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
|
|
54
59
|
}
|
|
55
60
|
mem.set(k, exp);
|
|
61
|
+
return true;
|
|
56
62
|
},
|
|
57
63
|
};
|
|
58
64
|
|
|
@@ -88,10 +94,11 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
|
|
|
88
94
|
if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
|
|
89
95
|
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
90
96
|
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
91
|
-
if (await used.has(token)) return { ok: false, reason: "already used" };
|
|
92
97
|
const h = await sha256(`${chal}:${nonce}`);
|
|
93
98
|
if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
94
|
-
|
|
99
|
+
// Atomically claim single-use only AFTER the work is validated, so an invalid
|
|
100
|
+
// attempt never consumes the token and concurrent dupes can't both pass.
|
|
101
|
+
if (!(await used.claim(token, Number(expStr)))) return { ok: false, reason: "already used" };
|
|
95
102
|
return { ok: true };
|
|
96
103
|
}
|
|
97
104
|
|
|
@@ -114,6 +121,10 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
114
121
|
secret,
|
|
115
122
|
store,
|
|
116
123
|
botUserAgents = AI_BOTS,
|
|
124
|
+
// "bots" (default, charge AI crawler UAs) | "all" (charge all but free()) |
|
|
125
|
+
// "strict" (charge anything that isn't a real-browser request). An explicit
|
|
126
|
+
// charge()/free() still wins. Default preserves the original behavior.
|
|
127
|
+
mode = "bots",
|
|
117
128
|
charge,
|
|
118
129
|
free,
|
|
119
130
|
resourceBaseUrl = "",
|
|
@@ -123,12 +134,19 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
123
134
|
const isBot = makeBotMatcher(botUserAgents);
|
|
124
135
|
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
125
136
|
|
|
137
|
+
const looksHuman = (request) => {
|
|
138
|
+
const ua = request.headers.get("user-agent") || "";
|
|
139
|
+
const accept = request.headers.get("accept") || "";
|
|
140
|
+
return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
|
|
141
|
+
};
|
|
126
142
|
const shouldCharge = (request) => {
|
|
127
143
|
try {
|
|
128
144
|
if (typeof free === "function" && free(request)) return false;
|
|
129
145
|
if (typeof charge === "function") return Boolean(charge(request));
|
|
130
146
|
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
131
|
-
|
|
147
|
+
if (mode === "all") return true;
|
|
148
|
+
if (mode === "strict") return !looksHuman(request);
|
|
149
|
+
return isBot(request.headers.get("user-agent") || ""); // "bots" (default)
|
|
132
150
|
};
|
|
133
151
|
|
|
134
152
|
return async function gate(request) {
|
package/index.js
CHANGED
|
@@ -55,22 +55,58 @@ export function createTollbooth(config = {}) {
|
|
|
55
55
|
powDifficulty,
|
|
56
56
|
powSecret,
|
|
57
57
|
botUserAgents = AI_BOTS,
|
|
58
|
+
// Who pays. Default "bots" = the original behavior (charge AI crawler UAs).
|
|
59
|
+
// "all" — charge every client except a `free()` match (UA detection is
|
|
60
|
+
// not a security boundary; this stops relying on it).
|
|
61
|
+
// "strict" — charge anything that isn't a real-browser request (browser-like
|
|
62
|
+
// UA + an HTML Accept). Raises the bar on naive scrapers.
|
|
63
|
+
// An explicit charge()/free() still wins over the mode.
|
|
64
|
+
mode = process.env.TOLLBOOTH_MODE || "bots",
|
|
58
65
|
charge,
|
|
59
66
|
free,
|
|
60
67
|
verifyX402,
|
|
61
68
|
resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
|
|
69
|
+
// Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
|
|
70
|
+
// high-volume abuse pays escalating CPU regardless of how it disguises itself.
|
|
71
|
+
// Off by default — behavior is unchanged unless explicitly enabled.
|
|
72
|
+
adaptive = config.adaptive ?? (process.env.TOLLBOOTH_ADAPTIVE === "true"),
|
|
73
|
+
adaptivePerBit = Number(process.env.TOLLBOOTH_ADAPTIVE_PER_BIT) || 300, // +1 bit per N charged req/min
|
|
74
|
+
maxDifficulty,
|
|
62
75
|
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.",
|
|
63
76
|
} = config;
|
|
64
77
|
|
|
65
78
|
const isBot = makeBotMatcher(botUserAgents);
|
|
66
79
|
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
67
80
|
|
|
81
|
+
// Passive analytics — never affects request handling, just counts what happens.
|
|
82
|
+
const stats = { since: new Date().toISOString(), requests: 0, freeAllowed: 0, charged: 0, powSolved: 0, x402Paid: 0 };
|
|
83
|
+
|
|
84
|
+
const looksHuman = (req) => {
|
|
85
|
+
const ua = req.headers["user-agent"] || "";
|
|
86
|
+
const accept = req.headers["accept"] || "";
|
|
87
|
+
return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
|
|
88
|
+
};
|
|
68
89
|
const shouldCharge = (req) => {
|
|
69
90
|
try {
|
|
70
91
|
if (typeof free === "function" && free(req)) return false;
|
|
71
92
|
if (typeof charge === "function") return Boolean(charge(req));
|
|
72
93
|
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
73
|
-
|
|
94
|
+
if (mode === "all") return true;
|
|
95
|
+
if (mode === "strict") return !looksHuman(req);
|
|
96
|
+
return isBot(req.headers["user-agent"] || ""); // "bots" (default)
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Sliding-window of recent charged requests → adaptive PoW difficulty.
|
|
100
|
+
const baseDifficulty = powEngine?.difficulty ?? (Number(process.env.TOLLBOOTH_POW_BITS) || 18);
|
|
101
|
+
const ceilDifficulty = Math.min(Number(maxDifficulty) || baseDifficulty + 6, 32);
|
|
102
|
+
const ADAPT_WINDOW_MS = 60_000;
|
|
103
|
+
let chargedWindow = [];
|
|
104
|
+
const difficultyNow = () => {
|
|
105
|
+
if (!adaptive) return baseDifficulty;
|
|
106
|
+
const cut = Date.now() - ADAPT_WINDOW_MS;
|
|
107
|
+
if (chargedWindow.length > 100_000) chargedWindow = chargedWindow.filter((t) => t > cut); // hard bound
|
|
108
|
+
else while (chargedWindow.length && chargedWindow[0] < cut) chargedWindow.shift();
|
|
109
|
+
return Math.min(baseDifficulty + Math.floor(chargedWindow.length / Math.max(1, adaptivePerBit)), ceilDifficulty);
|
|
74
110
|
};
|
|
75
111
|
const resourceOf = (req) => {
|
|
76
112
|
// Canonicalize to path+search (matches the edge impl) so the PoW binding is
|
|
@@ -82,10 +118,13 @@ export function createTollbooth(config = {}) {
|
|
|
82
118
|
};
|
|
83
119
|
|
|
84
120
|
function tollbooth(req, res, next) {
|
|
85
|
-
|
|
121
|
+
stats.requests++;
|
|
122
|
+
if (!shouldCharge(req)) { stats.freeAllowed++; return next(); }
|
|
86
123
|
const resource = resourceOf(req);
|
|
87
124
|
|
|
88
125
|
const send402 = (extra = {}) => {
|
|
126
|
+
stats.charged++;
|
|
127
|
+
if (adaptive) chargedWindow.push(Date.now());
|
|
89
128
|
const body = {
|
|
90
129
|
error: "Payment Required",
|
|
91
130
|
message,
|
|
@@ -94,7 +133,7 @@ export function createTollbooth(config = {}) {
|
|
|
94
133
|
: [],
|
|
95
134
|
...extra,
|
|
96
135
|
};
|
|
97
|
-
if (powEngine) body.proofOfWork = powEngine.challenge(resource);
|
|
136
|
+
if (powEngine) body.proofOfWork = powEngine.challenge(resource, difficultyNow());
|
|
98
137
|
res.status(402).json(body);
|
|
99
138
|
};
|
|
100
139
|
|
|
@@ -102,7 +141,7 @@ export function createTollbooth(config = {}) {
|
|
|
102
141
|
const powHeader = req.headers["x-pow-solution"];
|
|
103
142
|
if (powEngine && powHeader) {
|
|
104
143
|
const r = powEngine.verify(powHeader, resource);
|
|
105
|
-
if (r.ok) { res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
144
|
+
if (r.ok) { stats.powSolved++; res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
106
145
|
res.setHeader("X-Pow-Error", r.reason);
|
|
107
146
|
}
|
|
108
147
|
|
|
@@ -114,7 +153,7 @@ export function createTollbooth(config = {}) {
|
|
|
114
153
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
|
|
115
154
|
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
|
|
116
155
|
.then((ok) => {
|
|
117
|
-
if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
156
|
+
if (ok) { stats.x402Paid++; res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
118
157
|
send402();
|
|
119
158
|
})
|
|
120
159
|
.catch(() => { res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
|
|
@@ -125,6 +164,9 @@ export function createTollbooth(config = {}) {
|
|
|
125
164
|
|
|
126
165
|
tollbooth.shouldCharge = shouldCharge;
|
|
127
166
|
tollbooth.pow = powEngine;
|
|
167
|
+
// Live counters for operators: how much traffic, how much was charged, and how
|
|
168
|
+
// it was settled. A point-in-time snapshot (never mutated by the caller).
|
|
169
|
+
tollbooth.stats = () => ({ ...stats, difficultyNow: difficultyNow() });
|
|
128
170
|
return tollbooth;
|
|
129
171
|
}
|
|
130
172
|
|
|
@@ -187,6 +229,11 @@ async function startCli() {
|
|
|
187
229
|
const { default: express } = await import("express");
|
|
188
230
|
const app = express();
|
|
189
231
|
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
|
232
|
+
// Operator analytics — aggregate counts only (no per-request data), mounted
|
|
233
|
+
// before the gate so they're always reachable and never themselves charged.
|
|
234
|
+
const { dashboardHtml } = await import("./dashboard.js");
|
|
235
|
+
app.get("/__tollbooth", (_req, res) => res.type("html").send(dashboardHtml()));
|
|
236
|
+
app.get("/__tollbooth/stats", (_req, res) => res.json(gate.stats()));
|
|
190
237
|
app.use(gate);
|
|
191
238
|
if (upstream) {
|
|
192
239
|
app.use(createProxy(upstream));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"worker.js",
|
|
17
17
|
"bots.js",
|
|
18
18
|
"pow.js",
|
|
19
|
+
"dashboard.js",
|
|
19
20
|
"example.js",
|
|
20
21
|
"demo.js",
|
|
21
22
|
"README.md"
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"node": ">=20"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
|
-
"test": "node test.js && node edge.test.js",
|
|
28
|
+
"test": "node test.js && node edge.test.js && node features.test.js",
|
|
28
29
|
"demo": "node demo.js",
|
|
29
30
|
"example": "node example.js"
|
|
30
31
|
},
|
package/pow.js
CHANGED
|
@@ -35,19 +35,22 @@ export function createPow({
|
|
|
35
35
|
|
|
36
36
|
const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
37
37
|
|
|
38
|
-
/** Mint a signed, resource-scoped challenge.
|
|
39
|
-
|
|
38
|
+
/** Mint a signed, resource-scoped challenge. `diff` lets the caller raise the
|
|
39
|
+
* difficulty for this one challenge (e.g. adaptive-under-load); verify reads
|
|
40
|
+
* the difficulty from the token, so any value is self-describing and safe. */
|
|
41
|
+
function challenge(resource, diff = difficulty) {
|
|
42
|
+
const d = Math.max(1, Math.min(Math.floor(Number(diff) || difficulty), 32));
|
|
40
43
|
const chal = randomBytes(16).toString("hex");
|
|
41
44
|
const exp = Date.now() + ttlMs;
|
|
42
|
-
const payload = `${chal}.${exp}.${
|
|
45
|
+
const payload = `${chal}.${exp}.${d}.${resource}`;
|
|
43
46
|
const token = `${payload}.${sign(payload)}`;
|
|
44
47
|
return {
|
|
45
48
|
algorithm: "sha256",
|
|
46
49
|
challenge: chal,
|
|
47
|
-
difficulty,
|
|
50
|
+
difficulty: d,
|
|
48
51
|
expires: exp,
|
|
49
52
|
token,
|
|
50
|
-
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${
|
|
53
|
+
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${d} leading zero bits, then resend the request with header X-Pow-Solution: ${token}:<nonce>`,
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
|
package/worker.js
CHANGED
|
@@ -13,8 +13,14 @@
|
|
|
13
13
|
import { createEdgeTollbooth } from "./edge.js";
|
|
14
14
|
|
|
15
15
|
const kvStore = (kv) => ({
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Best-effort atomic claim. KV has no native compare-and-set, so this is
|
|
17
|
+
// get-then-put (eventually consistent); for strict single-use, back it with a
|
|
18
|
+
// Durable Object. Returns true only the first time a token is seen.
|
|
19
|
+
claim: async (k, expMs) => {
|
|
20
|
+
if ((await kv.get(k)) != null) return false;
|
|
21
|
+
await kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) });
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
18
24
|
});
|
|
19
25
|
|
|
20
26
|
export default {
|