agent402-tollbooth 0.3.1 → 0.4.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 +30 -4
- package/dashboard.js +223 -6
- package/edge.js +4 -0
- package/index.js +50 -6
- package/package.json +9 -4
- package/sinks.js +12 -1
- package/worker.js +1 -0
package/README.md
CHANGED
|
@@ -5,10 +5,15 @@ API: humans browse free, AI crawlers and agents pay per request** — either in
|
|
|
5
5
|
USDC over the [x402 protocol](https://x402.org), or for free by solving a
|
|
6
6
|
proof-of-work. No Cloudflare, no Stripe, no Merchant-of-Record, no signup.
|
|
7
7
|
|
|
8
|
-
The big platforms
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
The big platforms are converging on the same model: Cloudflare's
|
|
9
|
+
[pay-per-crawl](https://stackoverflow.blog/2026/02/26/how-pay-per-crawl-is-reshaping-data-monetization/)
|
|
10
|
+
and now its [Monetization Gateway](https://blog.cloudflare.com/monetization-gateway/)
|
|
11
|
+
(July 2026 — x402 charging for anything behind Cloudflare, waitlist) confirm that
|
|
12
|
+
pay-per-request is the business model of the agentic web. Tollbooth is the
|
|
13
|
+
**open-source monetization gateway**: the same idea, live today, MIT-licensed,
|
|
14
|
+
running in front of *any* origin (even a Cloudflare Worker), with a proof-of-work
|
|
15
|
+
free tier no platform offers — built on the same hardened 402 + proof-of-work
|
|
16
|
+
machinery as [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
12
17
|
|
|
13
18
|
## See it work (one command)
|
|
14
19
|
|
|
@@ -198,6 +203,10 @@ something*:
|
|
|
198
203
|
a proof-of-work.
|
|
199
204
|
- `mode: "strict"` charges anything that isn't a real-browser request (browser-like
|
|
200
205
|
UA **and** an HTML `Accept`), letting genuine human page-loads through free.
|
|
206
|
+
Heads-up: that's a heuristic, not a security boundary — a bot that sets
|
|
207
|
+
`User-Agent: Mozilla/5.0 …` + `Accept: text/html` gets the same free pass a
|
|
208
|
+
human gets. Use `mode: "all"` (or your own `charge:` predicate) for hard
|
|
209
|
+
guarantees.
|
|
201
210
|
- `adaptive: true` makes proof-of-work **harder as load climbs**, so a high-volume
|
|
202
211
|
scraper pays escalating CPU per request regardless of how it looks — detection is
|
|
203
212
|
cat-and-mouse, economics isn't.
|
|
@@ -311,3 +320,20 @@ as drop-in copyable snippets.
|
|
|
311
320
|
the exact resource (path + query, dots and all) — a solution for one URL can't
|
|
312
321
|
be replayed or reused on another.
|
|
313
322
|
- MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
323
|
+
|
|
324
|
+
## Charge in USDG on Robinhood Chain
|
|
325
|
+
|
|
326
|
+
The quote's network and asset are operator-configured, so the gate can charge
|
|
327
|
+
crawlers in **USDG (Global Dollar) on Robinhood Chain** (chain id 4663)
|
|
328
|
+
instead of USDC:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
TOLLBOOTH_PAYTO=0xYourWallet \
|
|
332
|
+
TOLLBOOTH_NETWORK=eip155:4663 \
|
|
333
|
+
TOLLBOOTH_ASSET=USDG \
|
|
334
|
+
npx agent402-tollbooth
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
or in code: `createTollbooth({ payTo, network: "eip155:4663", asset: "USDG", verifyX402 })`.
|
|
338
|
+
Wire `verifyX402` to an x402 facilitator that settles chain 4663. Defaults
|
|
339
|
+
are unchanged (USDC on Base).
|
package/dashboard.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// /__tollbooth by the reverse-proxy CLI. It polls /__tollbooth/stats and shows
|
|
3
3
|
// how much of your traffic is bots and what the gate is collecting — the visual
|
|
4
4
|
// answer to "I had to upgrade my plan to serve bots I don't monetize."
|
|
5
|
-
//
|
|
5
|
+
//
|
|
6
|
+
// Visual rhythm mirrors agent402.tools/analytics: stat-card grid, inline-SVG
|
|
7
|
+
// rolling sparkline (built client-side from poll deltas — no server changes,
|
|
8
|
+
// no third-party chart lib), and a window selector that changes how many
|
|
9
|
+
// recent polls feed the sparkline. Pure function (no deps) so it's trivially
|
|
10
|
+
// testable.
|
|
6
11
|
export function dashboardHtml() {
|
|
7
12
|
return `<!doctype html>
|
|
8
13
|
<html lang="en"><head>
|
|
@@ -12,7 +17,7 @@ export function dashboardHtml() {
|
|
|
12
17
|
<style>
|
|
13
18
|
:root{--bg:#0b0e14;--card:#131826;--text:#e6e9f0;--muted:#8b93a7;--accent:#4ade80;--line:#1e2638;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
14
19
|
*{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:
|
|
20
|
+
.wrap{max-width:920px;margin:0 auto;padding:40px 20px}
|
|
16
21
|
h1{font-size:1.4rem;display:flex;align-items:center;gap:10px}
|
|
17
22
|
.badge{font:700 14px/1 var(--mono);color:var(--accent);background:#000;border:1px solid #1f4a1d;border-radius:8px;padding:8px 10px}
|
|
18
23
|
.sub{color:var(--muted);margin:6px 0 24px;font-size:.9rem}
|
|
@@ -21,6 +26,28 @@ export function dashboardHtml() {
|
|
|
21
26
|
.k{color:var(--muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
|
|
22
27
|
.v{font:800 1.8rem/1.1 var(--mono);margin-top:8px}
|
|
23
28
|
.v.accent{color:var(--accent)}
|
|
29
|
+
.spark{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin-top:18px}
|
|
30
|
+
.spark .row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;flex-wrap:wrap}
|
|
31
|
+
.spark h2{font-size:.95rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600}
|
|
32
|
+
.spark .meta{display:flex;gap:16px;align-items:center;font:600 .8rem/1 var(--mono);color:var(--muted)}
|
|
33
|
+
.spark .meta b{color:var(--text);font-weight:700;margin-left:6px}
|
|
34
|
+
.spark .meta .paid{color:#4ade80}
|
|
35
|
+
.spark .windows{display:flex;gap:6px}
|
|
36
|
+
.spark .w{font:600 .8rem/1 var(--mono);color:var(--muted);background:transparent;border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer}
|
|
37
|
+
.spark .w.active{color:var(--accent);border-color:#1f4a1d;background:#000}
|
|
38
|
+
.spark svg{display:block;width:100%;height:80px}
|
|
39
|
+
.ratios{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-top:18px}
|
|
40
|
+
.ratios .card .v{font-size:1.5rem}
|
|
41
|
+
.ratios .card .hint{color:var(--muted);font-size:.75rem;margin-top:6px;font-family:var(--mono)}
|
|
42
|
+
.probes{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin-top:18px}
|
|
43
|
+
.probes h2{font-size:.95rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600;margin-bottom:6px}
|
|
44
|
+
.probes .sub{color:var(--muted);font-size:.8rem;margin-bottom:14px}
|
|
45
|
+
.probe{margin-top:10px}
|
|
46
|
+
.probe .label{color:var(--muted);font-size:.78rem;letter-spacing:.04em;text-transform:uppercase;display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
47
|
+
.probe .copy{font:600 .7rem/1 var(--mono);color:var(--muted);background:transparent;border:1px solid var(--line);border-radius:6px;padding:5px 8px;cursor:pointer}
|
|
48
|
+
.probe .copy:hover{color:var(--accent);border-color:#1f4a1d}
|
|
49
|
+
.probe .copy.done{color:var(--accent);border-color:#1f4a1d}
|
|
50
|
+
.probe pre{background:#000;border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-top:6px;overflow-x:auto;font:600 .82rem/1.45 var(--mono);color:var(--text)}
|
|
24
51
|
footer{color:var(--muted);font-size:.8rem;margin-top:28px}
|
|
25
52
|
a{color:var(--accent)}
|
|
26
53
|
</style></head>
|
|
@@ -28,7 +55,56 @@ export function dashboardHtml() {
|
|
|
28
55
|
<h1><span class="badge">402</span> agent402-tollbooth <span id="mode"></span></h1>
|
|
29
56
|
<div class="sub">Live pay-per-crawl stats · refreshes every 5s · <span id="since"></span></div>
|
|
30
57
|
<div class="grid" id="grid"></div>
|
|
31
|
-
<
|
|
58
|
+
<div class="spark">
|
|
59
|
+
<div class="row">
|
|
60
|
+
<h2>Requests / poll</h2>
|
|
61
|
+
<div class="meta">
|
|
62
|
+
<span>rate<b id="ratenow">—</b></span>
|
|
63
|
+
<span>peak<b id="ratepeak">—</b></span>
|
|
64
|
+
<span class="paid">paid<b id="paidnow">—</b></span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="windows" id="windows">
|
|
67
|
+
<button class="w" data-n="12">1m</button>
|
|
68
|
+
<button class="w active" data-n="60">5m</button>
|
|
69
|
+
<button class="w" data-n="180">15m</button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<svg viewBox="0 0 720 80" preserveAspectRatio="none" id="sparksvg" aria-hidden="true">
|
|
73
|
+
<polygon id="sparkfill" fill="#4ade80" fill-opacity="0.08" points=""/>
|
|
74
|
+
<polyline id="sparkline" fill="none" stroke="#4ade80" stroke-width="1.5" points=""/>
|
|
75
|
+
<polyline id="sparkpaid" fill="none" stroke="#facc15" stroke-width="1.2" stroke-dasharray="2 3" points=""/>
|
|
76
|
+
</svg>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="ratios">
|
|
79
|
+
<div class="card"><div class="k">Bot share</div><div class="v" id="botpct">—</div><div class="hint">charged + would-charge</div></div>
|
|
80
|
+
<div class="card"><div class="k">Paid conversion</div><div class="v accent" id="paidpct">—</div><div class="hint">of all requests, paid (PoW or USDC)</div></div>
|
|
81
|
+
<div class="card"><div class="k">Paid in USDC</div><div class="v accent" id="usdcpct">—</div><div class="hint">of paid, settled on Base</div></div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="probes" id="probes">
|
|
84
|
+
<h2>Operator probes</h2>
|
|
85
|
+
<div class="sub">Copy-paste curls — verify the gate is doing what these counters say.</div>
|
|
86
|
+
<div class="probe">
|
|
87
|
+
<div class="label"><span>Raw stats JSON (scrape this for metrics / alerts)</span><button class="copy" data-target="p1">copy</button></div>
|
|
88
|
+
<pre id="p1">curl -s <origin>/__tollbooth/stats | jq</pre>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="probe">
|
|
91
|
+
<div class="label"><span>Probe as a known bot — should see 402 (or pass-through in observe mode)</span><button class="copy" data-target="p2">copy</button></div>
|
|
92
|
+
<pre id="p2">curl -i -A 'GPTBot/1.0' <origin>/</pre>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="probe">
|
|
95
|
+
<div class="label"><span>Probe as a browser — should be free</span><button class="copy" data-target="p3">copy</button></div>
|
|
96
|
+
<pre id="p3">curl -i -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' <origin>/</pre>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="probe">
|
|
99
|
+
<div class="label"><span>Inspect the 402 challenge body (PoW or x402 instructions)</span><button class="copy" data-target="p4">copy</button></div>
|
|
100
|
+
<pre id="p4">curl -s -A 'GPTBot/1.0' <origin>/ | jq .</pre>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="probe">
|
|
103
|
+
<div class="label"><span>KPI grep — single-line dashboard for a shell</span><button class="copy" data-target="p5">copy</button></div>
|
|
104
|
+
<pre id="p5">curl -s <origin>/__tollbooth/stats | jq '{requests, charged, powSolved, x402Paid}'</pre>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<footer>Aggregate counts only (no per-request data). Raw JSON: <a href="/__tollbooth/stats">/__tollbooth/stats</a>.</footer>
|
|
32
108
|
</div>
|
|
33
109
|
<script>
|
|
34
110
|
const cards=[
|
|
@@ -40,6 +116,79 @@ const cards=[
|
|
|
40
116
|
["x402Paid","USDC paid (x402)","accent"],
|
|
41
117
|
["difficultyNow","PoW difficulty",false]
|
|
42
118
|
];
|
|
119
|
+
// Rolling poll-delta series for the sparkline. We retain up to MAX_POINTS
|
|
120
|
+
// raw deltas (one per 5s tick) and the active window selector controls how
|
|
121
|
+
// many of the tail points are plotted. Built client-side — no server-side
|
|
122
|
+
// timeseries store needed, and the data dies with the tab.
|
|
123
|
+
var MAX_POINTS=180; // 15 minutes at one poll per 5s
|
|
124
|
+
// Parallel deltas: total requests AND paid requests (PoW + USDC). Both are
|
|
125
|
+
// computed off the same tick cadence so they share an x-axis and a single
|
|
126
|
+
// y-scale. paidSeries is the dashed overlay — "is the bot traffic actually
|
|
127
|
+
// paying, or just arriving?" at a glance, no math required.
|
|
128
|
+
var series=[]; var paidSeries=[]; var lastRequests=null; var lastPaid=null; var activeWindow=60;
|
|
129
|
+
function drawSpark(){
|
|
130
|
+
var poly=document.getElementById("sparkline");
|
|
131
|
+
var fill=document.getElementById("sparkfill");
|
|
132
|
+
var paid=document.getElementById("sparkpaid");
|
|
133
|
+
if(!poly||!fill) return;
|
|
134
|
+
var pts=series.slice(-activeWindow);
|
|
135
|
+
var ppts=paidSeries.slice(-activeWindow);
|
|
136
|
+
if(!pts.length){poly.setAttribute("points","");fill.setAttribute("points","");if(paid)paid.setAttribute("points","");return;}
|
|
137
|
+
// Shared y-scale across both series so the paid overlay is comparable to
|
|
138
|
+
// the total. max>=1 keeps a zero-traffic chart from collapsing to NaN.
|
|
139
|
+
var max=1; for(var i=0;i<pts.length;i++){if(pts[i]>max)max=pts[i];}
|
|
140
|
+
for(var i2=0;i2<ppts.length;i2++){if(ppts[i2]>max)max=ppts[i2];}
|
|
141
|
+
var stepX=pts.length>1?720/(pts.length-1):0;
|
|
142
|
+
var coords=[];
|
|
143
|
+
for(var j=0;j<pts.length;j++){
|
|
144
|
+
var x=(j*stepX).toFixed(1);
|
|
145
|
+
var y=(80-(pts[j]/max)*76-2).toFixed(1);
|
|
146
|
+
coords.push(x+","+y);
|
|
147
|
+
}
|
|
148
|
+
poly.setAttribute("points",coords.join(" "));
|
|
149
|
+
fill.setAttribute("points","0,80 "+coords.join(" ")+" "+(720).toFixed(1)+",80");
|
|
150
|
+
if(paid){
|
|
151
|
+
var pcoords=[];
|
|
152
|
+
for(var k=0;k<ppts.length;k++){
|
|
153
|
+
var px=(k*stepX).toFixed(1);
|
|
154
|
+
var py=(80-(ppts[k]/max)*76-2).toFixed(1);
|
|
155
|
+
pcoords.push(px+","+py);
|
|
156
|
+
}
|
|
157
|
+
paid.setAttribute("points",pcoords.join(" "));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Operator meta: rate-now is the mean of the last 12 deltas (≈1m at 5s/tick),
|
|
161
|
+
// peak is the max delta inside the active window. Both answer "what's
|
|
162
|
+
// happening right now?" without forcing operators to eyeball the sparkline.
|
|
163
|
+
function updateMeta(){
|
|
164
|
+
var lastN=series.slice(-12);
|
|
165
|
+
var sum=0; for(var i=0;i<lastN.length;i++) sum+=lastN[i];
|
|
166
|
+
// Per-poll → per-minute: deltas are 5s apart, so * (60/5) = *12 / N points.
|
|
167
|
+
// Use *12 directly on the sum: 12 polls/minute / lastN.length gives per-min.
|
|
168
|
+
var perMin=lastN.length?Math.round((sum/lastN.length)*12):0;
|
|
169
|
+
var winPts=series.slice(-activeWindow);
|
|
170
|
+
var peak=0; for(var j=0;j<winPts.length;j++){if(winPts[j]>peak)peak=winPts[j];}
|
|
171
|
+
var paidLast=paidSeries.slice(-12);
|
|
172
|
+
var psum=0; for(var k=0;k<paidLast.length;k++) psum+=paidLast[k];
|
|
173
|
+
var paidPerMin=paidLast.length?Math.round((psum/paidLast.length)*12):0;
|
|
174
|
+
var elR=document.getElementById("ratenow"); if(elR) elR.textContent=perMin+"/min";
|
|
175
|
+
var elP=document.getElementById("ratepeak"); if(elP) elP.textContent=peak+"/poll";
|
|
176
|
+
var elPd=document.getElementById("paidnow"); if(elPd) elPd.textContent=paidPerMin+"/min";
|
|
177
|
+
}
|
|
178
|
+
function bindWindows(){
|
|
179
|
+
var bs=document.querySelectorAll("#windows .w");
|
|
180
|
+
for(var i=0;i<bs.length;i++){
|
|
181
|
+
(function(b){
|
|
182
|
+
b.addEventListener("click",function(){
|
|
183
|
+
activeWindow=Number(b.getAttribute("data-n"))||60;
|
|
184
|
+
for(var j=0;j<bs.length;j++) bs[j].classList.remove("active");
|
|
185
|
+
b.classList.add("active");
|
|
186
|
+
drawSpark();
|
|
187
|
+
updateMeta();
|
|
188
|
+
});
|
|
189
|
+
})(bs[i]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
43
192
|
async function tick(){
|
|
44
193
|
try{
|
|
45
194
|
const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
|
|
@@ -53,13 +202,81 @@ async function tick(){
|
|
|
53
202
|
var lbl=String(label).replace(/[&<>"']/g,function(ch){return({'&':'&','<':'<','>':'>','"':'"',"'":'''})[ch];});
|
|
54
203
|
return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
|
|
55
204
|
}).join("");
|
|
56
|
-
|
|
57
|
-
|
|
205
|
+
// Derived operator ratios — computed client-side so the gate stays a
|
|
206
|
+
// pure counter store. All inputs are coerced numbers (see render loop
|
|
207
|
+
// above), so a malicious snapshot can't smuggle non-numerics here.
|
|
208
|
+
var reqs=Number(s.requests)||0;
|
|
209
|
+
var botish=(Number(s.charged)||0)+(Number(s.wouldCharge)||0);
|
|
210
|
+
var pct=reqs?Math.round((botish/reqs)*100):0;
|
|
58
211
|
document.getElementById("botpct").textContent=pct+"%";
|
|
59
|
-
|
|
212
|
+
// Paid conversion: of all requests, what share actually settled (either
|
|
213
|
+
// PoW or USDC). Answers "is the gate converting traffic into payment?"
|
|
214
|
+
var pow=Number(s.powSolved)||0, usd=Number(s.x402Paid)||0, paid=pow+usd;
|
|
215
|
+
var paidPct=reqs?Math.round((paid/reqs)*100):0;
|
|
216
|
+
document.getElementById("paidpct").textContent=paidPct+"%";
|
|
217
|
+
// Paid-in-USDC share: of *paid* requests, how many settled in USDC vs PoW.
|
|
218
|
+
// Answers "are bots paying me real money, or just grinding compute?" "—"
|
|
219
|
+
// when there are no paid requests yet (avoids 0/0 NaN).
|
|
220
|
+
document.getElementById("usdcpct").textContent=paid?Math.round((usd/paid)*100)+"%":"\u2014";
|
|
221
|
+
document.getElementById("mode").textContent=s.observe?" \u00B7 OBSERVE":"";
|
|
60
222
|
if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
|
|
223
|
+
// Roll the series: delta of total requests since the last tick is the
|
|
224
|
+
// per-poll arrival rate. First tick seeds lastRequests/lastPaid with no
|
|
225
|
+
// plot — we need a baseline to subtract on tick #2.
|
|
226
|
+
var now=Number(s.requests); if(!Number.isFinite(now)) now=0;
|
|
227
|
+
var nowPaid=(Number(s.powSolved)||0)+(Number(s.x402Paid)||0);
|
|
228
|
+
if(lastRequests!==null){
|
|
229
|
+
var delta=Math.max(0,now-lastRequests);
|
|
230
|
+
var pDelta=Math.max(0,nowPaid-lastPaid);
|
|
231
|
+
series.push(delta);
|
|
232
|
+
paidSeries.push(pDelta);
|
|
233
|
+
if(series.length>MAX_POINTS) series.shift();
|
|
234
|
+
if(paidSeries.length>MAX_POINTS) paidSeries.shift();
|
|
235
|
+
drawSpark();
|
|
236
|
+
updateMeta();
|
|
237
|
+
}
|
|
238
|
+
lastRequests=now;
|
|
239
|
+
lastPaid=nowPaid;
|
|
61
240
|
}catch(e){/* keep last values */}
|
|
62
241
|
}
|
|
242
|
+
// Operator probes — replace the literal <origin> placeholder with the actual
|
|
243
|
+
// host so what the operator copies is what they can run, no edit step. Done
|
|
244
|
+
// once on load; probe text doesn't change after.
|
|
245
|
+
function initProbes(){
|
|
246
|
+
var origin=window.location.origin;
|
|
247
|
+
var pres=document.querySelectorAll("#probes pre");
|
|
248
|
+
for(var i=0;i<pres.length;i++){
|
|
249
|
+
pres[i].textContent=pres[i].textContent.replace(/<origin>/g,origin).replace(/<origin>/g,origin);
|
|
250
|
+
}
|
|
251
|
+
var btns=document.querySelectorAll("#probes .copy");
|
|
252
|
+
for(var j=0;j<btns.length;j++){
|
|
253
|
+
(function(b){
|
|
254
|
+
b.addEventListener("click",function(){
|
|
255
|
+
var t=document.getElementById(b.getAttribute("data-target"));
|
|
256
|
+
if(!t) return;
|
|
257
|
+
var txt=t.textContent;
|
|
258
|
+
// Prefer the modern Clipboard API; fall back to a hidden textarea
|
|
259
|
+
// selection for older browsers / non-secure contexts (file://).
|
|
260
|
+
var done=function(){
|
|
261
|
+
b.classList.add("done");
|
|
262
|
+
var orig=b.textContent; b.textContent="copied";
|
|
263
|
+
setTimeout(function(){b.classList.remove("done");b.textContent=orig;},1200);
|
|
264
|
+
};
|
|
265
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
266
|
+
navigator.clipboard.writeText(txt).then(done,function(){});
|
|
267
|
+
}else{
|
|
268
|
+
var ta=document.createElement("textarea");
|
|
269
|
+
ta.value=txt;ta.style.position="fixed";ta.style.left="-9999px";
|
|
270
|
+
document.body.appendChild(ta);ta.select();
|
|
271
|
+
try{document.execCommand("copy");done();}catch(e){}
|
|
272
|
+
document.body.removeChild(ta);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
})(btns[j]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
bindWindows();
|
|
279
|
+
initProbes();
|
|
63
280
|
tick();setInterval(tick,5000);
|
|
64
281
|
</script>
|
|
65
282
|
</body></html>`;
|
package/edge.js
CHANGED
|
@@ -153,6 +153,10 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
153
153
|
if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
|
|
154
154
|
};
|
|
155
155
|
|
|
156
|
+
// Heuristic only. A bot can trivially set `User-Agent: Mozilla/5.0 …` and
|
|
157
|
+
// `Accept: text/html` to pass — `strict` mode uses this as the lower bound
|
|
158
|
+
// (charge anything that does NOT look like a browser), not as proof of
|
|
159
|
+
// humanity. For hard guarantees use mode:"all" or `charge:` predicate.
|
|
156
160
|
const looksHuman = (request) => {
|
|
157
161
|
const ua = request.headers.get("user-agent") || "";
|
|
158
162
|
const accept = request.headers.get("accept") || "";
|
package/index.js
CHANGED
|
@@ -36,8 +36,8 @@ const STRIP_INBOUND = new Set([
|
|
|
36
36
|
* @param {object} [config]
|
|
37
37
|
* @param {string} [config.price="$0.001"] advertised price per request
|
|
38
38
|
* @param {string|null} [config.payTo] wallet for the x402 quote (enables USDC rail)
|
|
39
|
-
* @param {string} [config.network="base"] x402 network
|
|
40
|
-
* @param {string} [config.asset="USDC"]
|
|
39
|
+
* @param {string} [config.network="base"] x402 network (e.g. base, or robinhood / eip155:4663 for USDG)
|
|
40
|
+
* @param {string} [config.asset="USDC"] stablecoin symbol in the quote (USDG on Robinhood Chain)
|
|
41
41
|
* @param {boolean} [config.pow=true] enable the free proof-of-work rail
|
|
42
42
|
* @param {number} [config.powDifficulty] PoW difficulty in leading zero bits
|
|
43
43
|
* @param {string[]} [config.botUserAgents] user-agents to charge (default: AI_BOTS)
|
|
@@ -54,7 +54,10 @@ export function createTollbooth(config = {}) {
|
|
|
54
54
|
price = process.env.TOLLBOOTH_PRICE || "$0.001",
|
|
55
55
|
payTo = process.env.TOLLBOOTH_PAYTO || null,
|
|
56
56
|
network = process.env.TOLLBOOTH_NETWORK || "base",
|
|
57
|
-
|
|
57
|
+
// Any stablecoin the operator's facilitator settles. USDC on the default
|
|
58
|
+
// chains; set TOLLBOOTH_NETWORK=robinhood (or eip155:4663) with
|
|
59
|
+
// TOLLBOOTH_ASSET=USDG to charge crawlers in USDG on Robinhood Chain.
|
|
60
|
+
asset = process.env.TOLLBOOTH_ASSET || "USDC",
|
|
58
61
|
pow = true,
|
|
59
62
|
powDifficulty,
|
|
60
63
|
powSecret,
|
|
@@ -263,9 +266,50 @@ async function startCli() {
|
|
|
263
266
|
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
|
264
267
|
// Operator analytics — aggregate counts only (no per-request data), mounted
|
|
265
268
|
// before the gate so they're always reachable and never themselves charged.
|
|
269
|
+
// Two opt-in admin tokens (legacy `TOLLBOOTH_STATS_TOKEN` covers /stats only;
|
|
270
|
+
// `TOLLBOOTH_ADMIN_TOKEN` covers both the HTML dashboard AND /stats). If
|
|
271
|
+
// neither is set the surfaces remain public (aggregate counts only — current
|
|
272
|
+
// behavior) so existing deploys don't break. Comparison is timing-safe.
|
|
266
273
|
const { dashboardHtml } = await import("./dashboard.js");
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
const { timingSafeEqual } = await import("node:crypto");
|
|
275
|
+
const ADMIN_TOKEN = process.env.TOLLBOOTH_ADMIN_TOKEN || "";
|
|
276
|
+
const STATS_TOKEN = process.env.TOLLBOOTH_STATS_TOKEN || "";
|
|
277
|
+
const presented = (req) => {
|
|
278
|
+
const auth = req.headers["authorization"];
|
|
279
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice(7);
|
|
280
|
+
const hdr = req.headers["x-admin-token"];
|
|
281
|
+
if (typeof hdr === "string") return hdr;
|
|
282
|
+
return "";
|
|
283
|
+
};
|
|
284
|
+
// tokenMatch returns true only on a real, timing-safe equal match. An empty
|
|
285
|
+
// `expected` is treated as "no rule configured", NOT as a wildcard — callers
|
|
286
|
+
// upstream decide whether to invoke this check at all.
|
|
287
|
+
const tokenMatch = (expected, got) => {
|
|
288
|
+
if (!expected || typeof got !== "string" || got.length !== expected.length) return false;
|
|
289
|
+
try { return timingSafeEqual(Buffer.from(got), Buffer.from(expected)); }
|
|
290
|
+
catch { return false; }
|
|
291
|
+
};
|
|
292
|
+
app.get("/__tollbooth", (req, res) => {
|
|
293
|
+
if (ADMIN_TOKEN && !tokenMatch(ADMIN_TOKEN, presented(req))) {
|
|
294
|
+
res.setHeader("WWW-Authenticate", 'Bearer realm="tollbooth"');
|
|
295
|
+
return res.status(401).type("text/plain").send("Unauthorized");
|
|
296
|
+
}
|
|
297
|
+
res.type("html").send(dashboardHtml());
|
|
298
|
+
});
|
|
299
|
+
app.get("/__tollbooth/stats", (req, res) => {
|
|
300
|
+
// Either ADMIN_TOKEN or STATS_TOKEN unlocks /stats; either being set turns
|
|
301
|
+
// the endpoint from public to gated, and the presented value must match
|
|
302
|
+
// ONE of the configured tokens.
|
|
303
|
+
const gated = Boolean(ADMIN_TOKEN || STATS_TOKEN);
|
|
304
|
+
if (gated) {
|
|
305
|
+
const got = presented(req);
|
|
306
|
+
if (!tokenMatch(ADMIN_TOKEN, got) && !tokenMatch(STATS_TOKEN, got)) {
|
|
307
|
+
res.setHeader("WWW-Authenticate", 'Bearer realm="tollbooth"');
|
|
308
|
+
return res.status(401).type("text/plain").send("Unauthorized");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
res.json(gate.stats());
|
|
312
|
+
});
|
|
269
313
|
app.use(gate);
|
|
270
314
|
if (upstream) {
|
|
271
315
|
app.use(createProxy(upstream));
|
|
@@ -273,7 +317,7 @@ async function startCli() {
|
|
|
273
317
|
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." }));
|
|
274
318
|
}
|
|
275
319
|
app.listen(port, () => {
|
|
276
|
-
const rails = `${gate.pow ? "proof-of-work" : ""}${process.env.TOLLBOOTH_PAYTO ? (gate.pow ?
|
|
320
|
+
const rails = `${gate.pow ? "proof-of-work" : ""}${process.env.TOLLBOOTH_PAYTO ? (gate.pow ? ` + x402(${process.env.TOLLBOOTH_ASSET || "USDC"})` : `x402(${process.env.TOLLBOOTH_ASSET || "USDC"})`) : ""}`;
|
|
277
321
|
console.log(`agent402-tollbooth listening on :${port} — charging AI bots via ${rails || "proof-of-work"}`);
|
|
278
322
|
if (upstream) console.log(` proxying → ${upstream}`);
|
|
279
323
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402 — or USDG on Robinhood Chain — 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",
|
|
7
7
|
"bin": {
|
|
@@ -56,9 +56,14 @@
|
|
|
56
56
|
"edge",
|
|
57
57
|
"self-hosted",
|
|
58
58
|
"mcp",
|
|
59
|
-
"ai-agents"
|
|
59
|
+
"ai-agents",
|
|
60
|
+
"agent402",
|
|
61
|
+
"tollbooth",
|
|
62
|
+
"base",
|
|
63
|
+
"robinhood-chain",
|
|
64
|
+
"usdg"
|
|
60
65
|
],
|
|
61
|
-
"author": "
|
|
66
|
+
"author": "Mike Petrillo <mike@agent402.tools> (https://github.com/MikeyPetrillo)",
|
|
62
67
|
"license": "MIT",
|
|
63
68
|
"repository": {
|
|
64
69
|
"type": "git",
|
package/sinks.js
CHANGED
|
@@ -116,9 +116,20 @@ export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24
|
|
|
116
116
|
*
|
|
117
117
|
* Auth: pass `token` to include `Authorization: Bearer <token>`.
|
|
118
118
|
*/
|
|
119
|
-
export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
|
|
119
|
+
export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl, allowInsecure } = {}) {
|
|
120
120
|
const f = fetchImpl || globalThis.fetch;
|
|
121
121
|
if (typeof f !== "function") throw new Error("httpStatsSink: no fetch available (pass fetchImpl or run on Node 18+)");
|
|
122
|
+
// Bearer tokens MUST NOT leak over plaintext. Require HTTPS unless the caller
|
|
123
|
+
// explicitly opts in (loopback dev, in-cluster service mesh that terminates
|
|
124
|
+
// TLS upstream). The token is the only thing protecting the collector from
|
|
125
|
+
// forged stats batches.
|
|
126
|
+
if (token) {
|
|
127
|
+
let proto = "";
|
|
128
|
+
try { proto = new URL(url).protocol; } catch { /* let fetch surface the error later */ }
|
|
129
|
+
if (proto && proto !== "https:" && !allowInsecure) {
|
|
130
|
+
throw new Error("httpStatsSink: refusing to send a bearer token over a non-HTTPS URL. Use https:// or pass { allowInsecure: true } for trusted private networks.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
122
133
|
const buf = {};
|
|
123
134
|
let timer = null;
|
|
124
135
|
const headers = { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
|
package/worker.js
CHANGED
|
@@ -58,6 +58,7 @@ export default {
|
|
|
58
58
|
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
59
59
|
payTo: env.TOLLBOOTH_PAYTO || null,
|
|
60
60
|
network: env.TOLLBOOTH_NETWORK || "base",
|
|
61
|
+
asset: env.TOLLBOOTH_ASSET || "USDC",
|
|
61
62
|
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
62
63
|
store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
|
|
63
64
|
observe: env.TOLLBOOTH_OBSERVE === "true",
|