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 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 ([Cloudflare](https://stackoverflow.blog/2026/02/26/how-pay-per-crawl-is-reshaping-data-monetization/),
9
- Stack Overflow) shipped pay-per-crawl as a closed, fiat, you-must-be-on-our-CDN
10
- feature. This is the open, crypto-native, run-it-yourself version — built on the
11
- same hardened 402 + proof-of-work machinery as [Agent402](https://github.com/MikeyPetrillo/Agent402).
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
- // Pure function (no deps) so it's trivially testable.
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:880px;margin:0 auto;padding:40px 20px}
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
- <footer><b id="botpct">—</b> of requests were classified as AI bots. Aggregate counts only (no per-request data). Raw JSON: <a href="/__tollbooth/stats">/__tollbooth/stats</a>.</footer>
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 &lt;origin&gt;/__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' &lt;origin&gt;/</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' &lt;origin&gt;/</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' &lt;origin&gt;/ | 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 &lt;origin&gt;/__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({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[ch];});
54
203
  return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
55
204
  }).join("");
56
- var botish=(s.charged||0)+(s.wouldCharge||0);
57
- var pct=s.requests?Math.round((botish/s.requests)*100):0;
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
- document.getElementById("mode").textContent=s.observe?" · OBSERVE":"";
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(/&lt;origin&gt;/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
- asset = "USDC",
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
- app.get("/__tollbooth", (_req, res) => res.type("html").send(dashboardHtml()));
268
- app.get("/__tollbooth/stats", (_req, res) => res.json(gate.stats()));
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 ? " + x402(USDC)" : "x402(USDC)") : ""}`;
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.3.1",
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.",
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": "Mikey Petrillo (https://github.com/MikeyPetrillo)",
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",