agent402-tollbooth 0.3.0 → 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,21 +116,167 @@ 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();
195
+ // Counters are always numbers — coerce, never trust raw JSON from the
196
+ // configured stats endpoint (a compromised collector with httpStatsSink
197
+ // could otherwise inject HTML through this innerHTML sink).
46
198
  document.getElementById("grid").innerHTML=cards.map(function(c){
47
199
  var k=c[0],label=c[1],acc=c[2];
48
- if(k==="wouldCharge" && !(s.wouldCharge>0) && !s.observe) return "";
49
- return '<div class="card"><div class="k">'+label+'</div><div class="v '+(acc||"")+'">'+(s[k]??0)+'</div></div>';
200
+ if(k==="wouldCharge" && !(Number(s.wouldCharge)>0) && !s.observe) return "";
201
+ var v=Number(s[k]); if(!Number.isFinite(v)) v=0;
202
+ var lbl=String(label).replace(/[&<>"']/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[ch];});
203
+ return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
50
204
  }).join("");
51
- var botish=(s.charged||0)+(s.wouldCharge||0);
52
- 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;
53
211
  document.getElementById("botpct").textContent=pct+"%";
54
- 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":"";
55
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;
56
240
  }catch(e){/* keep last values */}
57
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();
58
280
  tick();setInterval(tick,5000);
59
281
  </script>
60
282
  </body></html>`;
package/edge.js CHANGED
@@ -146,8 +146,17 @@ export function createEdgeTollbooth(config = {}) {
146
146
  const mem = memorySink();
147
147
  const sink = statsSink || mem;
148
148
  const writeThrough = statsSink && statsSink !== mem;
149
- const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
149
+ // Stats must never break a request path — a buggy custom sink can throw
150
+ // synchronously; we shrug and continue.
151
+ const incr = (k, n = 1) => {
152
+ try { mem.incr(k, n); } catch { /* ignore */ }
153
+ if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
154
+ };
150
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.
151
160
  const looksHuman = (request) => {
152
161
  const ua = request.headers.get("user-agent") || "";
153
162
  const accept = request.headers.get("accept") || "";
@@ -195,6 +204,8 @@ export function createEdgeTollbooth(config = {}) {
195
204
  gate.observe = observe;
196
205
  gate.stats = () => ({ ...mem.snapshot(), observe });
197
206
  gate.snapshot = async () => ({ ...(await sink.snapshot()), observe });
198
- gate.flush = () => Promise.resolve(sink.flush && sink.flush());
207
+ // flush() is typically wired to ctx.waitUntil — swallow errors so a sink
208
+ // outage can't surface as an unhandled rejection after the response.
209
+ gate.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
199
210
  return gate;
200
211
  }
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,
@@ -96,7 +99,12 @@ export function createTollbooth(config = {}) {
96
99
  const mem = memorySink();
97
100
  const sink = statsSink || mem;
98
101
  const writeThrough = statsSink && statsSink !== mem;
99
- const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
102
+ // Never let a buggy custom sink throw inside the request path — stats are
103
+ // non-critical and must not be able to break a payment decision.
104
+ const incr = (k, n = 1) => {
105
+ try { mem.incr(k, n); } catch { /* ignore */ }
106
+ if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
107
+ };
100
108
 
101
109
  const looksHuman = (req) => {
102
110
  const ua = req.headers["user-agent"] || "";
@@ -190,7 +198,10 @@ export function createTollbooth(config = {}) {
190
198
  // .stats() is sync (in-process mirror). .snapshot() is async (durable sink).
191
199
  tollbooth.stats = () => ({ ...mem.snapshot(), difficultyNow: difficultyNow(), observe });
192
200
  tollbooth.snapshot = async () => ({ ...(await sink.snapshot()), difficultyNow: difficultyNow(), observe });
193
- tollbooth.flush = () => Promise.resolve(sink.flush && sink.flush());
201
+ // Swallow flush errors — flush() is typically wired to ctx.waitUntil on the
202
+ // edge; an unhandled rejection there pollutes logs without affecting the
203
+ // already-sent response.
204
+ tollbooth.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
194
205
  return tollbooth;
195
206
  }
196
207
 
@@ -255,9 +266,50 @@ async function startCli() {
255
266
  const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
256
267
  // Operator analytics — aggregate counts only (no per-request data), mounted
257
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.
258
273
  const { dashboardHtml } = await import("./dashboard.js");
259
- app.get("/__tollbooth", (_req, res) => res.type("html").send(dashboardHtml()));
260
- 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
+ });
261
313
  app.use(gate);
262
314
  if (upstream) {
263
315
  app.use(createProxy(upstream));
@@ -265,7 +317,7 @@ async function startCli() {
265
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." }));
266
318
  }
267
319
  app.listen(port, () => {
268
- 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"})`) : ""}`;
269
321
  console.log(`agent402-tollbooth listening on :${port} — charging AI bots via ${rails || "proof-of-work"}`);
270
322
  if (upstream) console.log(` proxying → ${upstream}`);
271
323
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.3.0",
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
@@ -16,14 +16,24 @@
16
16
  // Want strong consistency on Cloudflare? Back it with a Durable Object that
17
17
  // implements the same { incr, snapshot } interface and pass it as `statsSink`.
18
18
 
19
- const ZERO = () => ({
20
- requests: 0,
21
- freeAllowed: 0,
22
- wouldCharge: 0,
23
- charged: 0,
24
- powSolved: 0,
25
- x402Paid: 0,
26
- });
19
+ const KNOWN_FIELDS = ["requests", "freeAllowed", "wouldCharge", "charged", "powSolved", "x402Paid"];
20
+ const ZERO = () => Object.fromEntries(KNOWN_FIELDS.map((k) => [k, 0]));
21
+
22
+ // Filter an untrusted JSON blob (KV value or HTTP collector response) to only
23
+ // the known numeric counters. Prevents a compromised store from injecting
24
+ // arbitrary keys or non-numeric values that downstream renderers (e.g.
25
+ // dashboard.js) might treat as HTML. Defense in depth — the dashboard also
26
+ // coerces, but trim at the boundary.
27
+ const sanitize = (obj) => {
28
+ const out = ZERO();
29
+ if (!obj || typeof obj !== "object") return out;
30
+ for (const k of KNOWN_FIELDS) {
31
+ const v = Number(obj[k]);
32
+ if (Number.isFinite(v) && v >= 0) out[k] = v;
33
+ }
34
+ if (typeof obj.since === "string" && obj.since.length < 64) out.since = obj.since;
35
+ return out;
36
+ };
27
37
 
28
38
  /** In-process counter. Synchronous; loses state on restart. */
29
39
  export function memorySink() {
@@ -77,9 +87,11 @@ export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24
77
87
  if (!raw) continue;
78
88
  let obj; try { obj = JSON.parse(raw); } catch { continue; }
79
89
  if (typeof obj._ts === "number" && obj._ts < oldest) oldest = obj._ts;
80
- for (const [k, n] of Object.entries(obj)) {
81
- if (k === "_ts") continue;
82
- if (typeof n === "number") result[k] = (result[k] || 0) + n;
90
+ // Whitelist + numeric coerce: a misconfigured (or compromised) KV
91
+ // entry can't smuggle arbitrary keys or strings into the snapshot.
92
+ for (const k of KNOWN_FIELDS) {
93
+ const v = Number(obj[k]);
94
+ if (Number.isFinite(v) && v >= 0) result[k] = (result[k] || 0) + v;
83
95
  }
84
96
  }
85
97
  cursor = page.cursor;
@@ -104,9 +116,20 @@ export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24
104
116
  *
105
117
  * Auth: pass `token` to include `Authorization: Bearer <token>`.
106
118
  */
107
- export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
119
+ export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl, allowInsecure } = {}) {
108
120
  const f = fetchImpl || globalThis.fetch;
109
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
+ }
110
133
  const buf = {};
111
134
  let timer = null;
112
135
  const headers = { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
@@ -140,8 +163,10 @@ export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
140
163
  signal: AbortSignal.timeout(5000),
141
164
  });
142
165
  if (!r.ok) return ZERO();
143
- const j = await r.json();
144
- return { ...ZERO(), ...j };
166
+ // Sanitize at the trust boundary — a compromised or misconfigured
167
+ // collector cannot inject non-numeric values or extra keys that the
168
+ // dashboard would render as HTML.
169
+ return sanitize(await r.json());
145
170
  } catch { return ZERO(); }
146
171
  },
147
172
  };
package/worker.js CHANGED
@@ -16,6 +16,17 @@
16
16
  import { createEdgeTollbooth, kvStatsSink } from "./edge.js";
17
17
  import { dashboardHtml } from "./dashboard.js";
18
18
 
19
+ // Constant-time string compare — Cloudflare Workers don't ship node:crypto's
20
+ // timingSafeEqual. Short-circuiting `===` on a secret token leaks length and
21
+ // prefix bits to a sufficiently patient attacker; this doesn't.
22
+ function constEq(a, b) {
23
+ a = String(a || ""); b = String(b || "");
24
+ if (a.length !== b.length) return false;
25
+ let r = 0;
26
+ for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
27
+ return r === 0;
28
+ }
29
+
19
30
  const kvStore = (kv) => ({
20
31
  // Best-effort atomic claim. KV has no native compare-and-set, so this is
21
32
  // get-then-put (eventually consistent); for strict single-use, back it with a
@@ -47,6 +58,7 @@ export default {
47
58
  price: env.TOLLBOOTH_PRICE || "$0.001",
48
59
  payTo: env.TOLLBOOTH_PAYTO || null,
49
60
  network: env.TOLLBOOTH_NETWORK || "base",
61
+ asset: env.TOLLBOOTH_ASSET || "USDC",
50
62
  powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
51
63
  store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
52
64
  observe: env.TOLLBOOTH_OBSERVE === "true",
@@ -61,10 +73,13 @@ export default {
61
73
  return new Response(dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
62
74
  }
63
75
  if (u.pathname === "/__tollbooth/stats") {
64
- // Optional bearer-token gate — share with your monitoring caller.
76
+ // Optional bearer-token gate — share with your monitoring caller. We
77
+ // recommend setting this in any prod deploy: without it, anyone on the
78
+ // internet can read aggregate counts (no per-request data, but still
79
+ // potentially sensitive competitive info).
65
80
  if (env.TOLLBOOTH_STATS_TOKEN) {
66
81
  const got = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
67
- if (got !== env.TOLLBOOTH_STATS_TOKEN) return new Response("unauthorized", { status: 401 });
82
+ if (!constEq(got, env.TOLLBOOTH_STATS_TOKEN)) return new Response("unauthorized", { status: 401 });
68
83
  }
69
84
  const snap = await gate.snapshot();
70
85
  return new Response(JSON.stringify(snap), { headers: { "content-type": "application/json" } });