agent402-tollbooth 0.10.4 → 0.10.5

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/bots.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Default set of AI / LLM crawler and agent user-agents to charge.
2
2
  //
3
3
  // Deliberately AI-focused: traditional search indexers (Googlebot, Bingbot,
4
- // DuckDuckBot) are NOT here — you almost always want classic SEO indexing to
4
+ // DuckDuckBot) are NOT here, you almost always want classic SEO indexing to
5
5
  // stay free. Override the whole list with `botUserAgents`, or supply your own
6
6
  // `charge(req)` predicate for full control.
7
7
  export const AI_BOTS = [
package/dashboard.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // A tiny self-contained operator dashboard for the tollbooth, served at
2
2
  // /__tollbooth by the reverse-proxy CLI. It polls /__tollbooth/stats and shows
3
- // how much of your traffic is bots and what the gate is collecting — the visual
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
6
  // Visual rhythm mirrors agent402.tools/analytics: stat-card grid, inline-SVG
7
- // rolling sparkline (built client-side from poll deltas — no server changes,
7
+ // rolling sparkline (built client-side from poll deltas, no server changes,
8
8
  // no third-party chart lib), and a window selector that changes how many
9
9
  // recent polls feed the sparkline. Pure function (no deps) so it's trivially
10
10
  // testable.
@@ -13,7 +13,7 @@ export function dashboardHtml() {
13
13
  <html lang="en"><head>
14
14
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
15
15
  <meta name="robots" content="noindex">
16
- <title>agent402-tollbooth — live stats</title>
16
+ <title>agent402-tollbooth: live stats</title>
17
17
  <style>
18
18
  :root{--bg:#0b0e14;--card:#131826;--text:#e6e9f0;--muted:#8b93a7;--accent:#4ade80;--line:#1e2638;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
19
19
  *{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,sans-serif}
@@ -59,9 +59,9 @@ export function dashboardHtml() {
59
59
  <div class="row">
60
60
  <h2>Requests / poll</h2>
61
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>
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
65
  </div>
66
66
  <div class="windows" id="windows">
67
67
  <button class="w" data-n="12">1m</button>
@@ -76,23 +76,23 @@ export function dashboardHtml() {
76
76
  </svg>
77
77
  </div>
78
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>
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
82
  </div>
83
83
  <div class="probes" id="probes">
84
84
  <h2>Operator probes</h2>
85
- <div class="sub">Copy-paste curls — verify the gate is doing what these counters say.</div>
85
+ <div class="sub">Copy-paste curls: verify the gate is doing what these counters say.</div>
86
86
  <div class="probe">
87
87
  <div class="label"><span>Raw stats JSON (scrape this for metrics / alerts)</span><button class="copy" data-target="p1">copy</button></div>
88
88
  <pre id="p1">curl -s &lt;origin&gt;/__tollbooth/stats | jq</pre>
89
89
  </div>
90
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>
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
92
  <pre id="p2">curl -i -A 'GPTBot/1.0' &lt;origin&gt;/</pre>
93
93
  </div>
94
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>
95
+ <div class="label"><span>Probe as a browser: should be free</span><button class="copy" data-target="p3">copy</button></div>
96
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
97
  </div>
98
98
  <div class="probe">
@@ -100,7 +100,7 @@ export function dashboardHtml() {
100
100
  <pre id="p4">curl -s -A 'GPTBot/1.0' &lt;origin&gt;/ | jq .</pre>
101
101
  </div>
102
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>
103
+ <div class="label"><span>KPI grep: a single-line dashboard for a shell</span><button class="copy" data-target="p5">copy</button></div>
104
104
  <pre id="p5">curl -s &lt;origin&gt;/__tollbooth/stats | jq '{requests, charged, powSolved, x402Paid, mppPaid}'</pre>
105
105
  </div>
106
106
  </div>
@@ -119,12 +119,12 @@ const cards=[
119
119
  ];
120
120
  // Rolling poll-delta series for the sparkline. We retain up to MAX_POINTS
121
121
  // raw deltas (one per 5s tick) and the active window selector controls how
122
- // many of the tail points are plotted. Built client-side — no server-side
122
+ // many of the tail points are plotted. Built client-side, no server-side
123
123
  // timeseries store needed, and the data dies with the tab.
124
124
  var MAX_POINTS=180; // 15 minutes at one poll per 5s
125
125
  // Parallel deltas: total requests AND paid requests (PoW + USDC). Both are
126
126
  // computed off the same tick cadence so they share an x-axis and a single
127
- // y-scale. paidSeries is the dashed overlay — "is the bot traffic actually
127
+ // y-scale. paidSeries is the dashed overlay, "is the bot traffic actually
128
128
  // paying, or just arriving?" at a glance, no math required.
129
129
  var series=[]; var paidSeries=[]; var lastRequests=null; var lastPaid=null; var activeWindow=60;
130
130
  function drawSpark(){
@@ -193,7 +193,7 @@ function bindWindows(){
193
193
  async function tick(){
194
194
  try{
195
195
  const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
196
- // Counters are always numbers — coerce, never trust raw JSON from the
196
+ // Counters are always numbers, coerce, never trust raw JSON from the
197
197
  // configured stats endpoint (a compromised collector with httpStatsSink
198
198
  // could otherwise inject HTML through this innerHTML sink).
199
199
  document.getElementById("grid").innerHTML=cards.map(function(c){
@@ -203,7 +203,7 @@ async function tick(){
203
203
  var lbl=String(label).replace(/[&<>"']/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[ch];});
204
204
  return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
205
205
  }).join("");
206
- // Derived operator ratios — computed client-side so the gate stays a
206
+ // Derived operator ratios, computed client-side so the gate stays a
207
207
  // pure counter store. All inputs are coerced numbers (see render loop
208
208
  // above), so a malicious snapshot can't smuggle non-numerics here.
209
209
  var reqs=Number(s.requests)||0;
@@ -216,14 +216,14 @@ async function tick(){
216
216
  var paidPct=reqs?Math.round((paid/reqs)*100):0;
217
217
  document.getElementById("paidpct").textContent=paidPct+"%";
218
218
  // Paid-in-USDC share: of *paid* requests, how many settled in USDC vs PoW.
219
- // Answers "are bots paying me real money, or just grinding compute?" "—"
219
+ // Answers "are bots paying me real money, or just grinding compute?" ", "
220
220
  // when there are no paid requests yet (avoids 0/0 NaN).
221
221
  document.getElementById("usdcpct").textContent=paid?Math.round((usd/paid)*100)+"%":"\u2014";
222
222
  document.getElementById("mode").textContent=s.observe?" \u00B7 OBSERVE":"";
223
223
  if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
224
224
  // Roll the series: delta of total requests since the last tick is the
225
225
  // per-poll arrival rate. First tick seeds lastRequests/lastPaid with no
226
- // plot — we need a baseline to subtract on tick #2.
226
+ // plot: we need a baseline to subtract on tick #2.
227
227
  var now=Number(s.requests); if(!Number.isFinite(now)) now=0;
228
228
  var nowPaid=(Number(s.powSolved)||0)+(Number(s.x402Paid)||0)+(Number(s.mppPaid)||0);
229
229
  if(lastRequests!==null){
@@ -240,7 +240,7 @@ async function tick(){
240
240
  lastPaid=nowPaid;
241
241
  }catch(e){/* keep last values */}
242
242
  }
243
- // Operator probes — replace the literal <origin> placeholder with the actual
243
+ // Operator probes, replace the literal <origin> placeholder with the actual
244
244
  // host so what the operator copies is what they can run, no edit step. Done
245
245
  // once on load; probe text doesn't change after.
246
246
  function initProbes(){
package/demo.js CHANGED
@@ -14,7 +14,7 @@ const lz = (buf) => { let b = 0; for (const x of buf) { if (x === 0) { b += 8; c
14
14
  // A "premium content" site, fronted by the tollbooth.
15
15
  const app = express();
16
16
  app.use(createTollbooth({ price: "$0.002", payTo: "0xYourWalletHere000000000000000000000000", powDifficulty: 18 }));
17
- app.use((_req, res) => res.send("📄 The Future of Machine Payments — full article text…"));
17
+ app.use((_req, res) => res.send("📄 The Future of Machine Payments: full article text…"));
18
18
  const server = app.listen(0);
19
19
  const base = `http://localhost:${server.address().port}`;
20
20
  const visit = (ua, extra = {}) => fetch(`${base}/article`, { headers: { "user-agent": ua, ...extra } });
@@ -23,16 +23,16 @@ const HUMAN = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.3
23
23
  const CRAWLER = "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)";
24
24
 
25
25
  try {
26
- log(`${c.bold}agent402-tollbooth — live pay-per-crawl demo${c.reset}`);
26
+ log(`${c.bold}agent402-tollbooth: live pay-per-crawl demo${c.reset}`);
27
27
  log(`${c.dim}A premium-content site is now running behind the tollbooth.${c.reset}\n`);
28
28
 
29
- // 1) A human browses — free.
29
+ // 1) A human browses, free.
30
30
  log(`${c.cyan}① A human opens the page (normal browser)${c.reset}`);
31
31
  let r = await visit(HUMAN);
32
32
  log(` → HTTP ${r.status} ${c.green}FREE${c.reset} "${await r.text()}"`);
33
33
  log(` ${c.dim}Humans are never charged.${c.reset}\n`);
34
34
 
35
- // 2) An AI crawler hits the same page — 402.
35
+ // 2) An AI crawler hits the same page, 402.
36
36
  log(`${c.cyan}② An AI crawler hits the same page (ClaudeBot)${c.reset}`);
37
37
  r = await visit(CRAWLER);
38
38
  const q = await r.json();
@@ -48,11 +48,11 @@ try {
48
48
  while (lz(createHash("sha256").update(`${q.proofOfWork.challenge}:${nonce}`).digest()) < q.proofOfWork.difficulty) nonce++;
49
49
  log(` ${c.dim}solved in ${((Date.now() - t0) / 1000).toFixed(2)}s (nonce=${nonce})${c.reset}`);
50
50
 
51
- // 4) Retry with the solution — unlocked.
51
+ // 4) Retry with the solution, unlocked.
52
52
  r = await visit(CRAWLER, { "x-pow-solution": `${q.proofOfWork.token}:${nonce}` });
53
53
  log(` → HTTP ${r.status} ${c.green}OK${c.reset} (paid via ${r.headers.get("x-tollbooth-paid")}) "${await r.text()}"\n`);
54
54
 
55
- log(`${c.bold}${c.green}✓ Pay-per-crawl, end to end${c.reset} — humans free, bots pay (USDC or compute). No Cloudflare, no Stripe, no signup.`);
55
+ log(`${c.bold}${c.green}✓ Pay-per-crawl, end to end${c.reset}: humans free, bots pay (USDC or compute). No Cloudflare, no Stripe, no signup.`);
56
56
  } finally {
57
57
  server.close();
58
58
  }
package/edge.js CHANGED
@@ -1,4 +1,4 @@
1
- // Edge / runtime-agnostic tollbooth — the same pay-per-crawl gate, built on the
1
+ // Edge / runtime-agnostic tollbooth, the same pay-per-crawl gate, built on the
2
2
  // Web Crypto + Fetch APIs so it runs anywhere: Cloudflare Workers, Next.js
3
3
  // middleware (edge), Deno, Bun, and Node 20+. No node:* imports.
4
4
  //
@@ -42,7 +42,7 @@ function constEq(a, b) {
42
42
  * Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
43
43
  * random per-process secret across invocations). `store` is an optional
44
44
  * single-use replay backend: { claim(token, expMs) => bool|Promise<bool> } that
45
- * atomically returns true only the first time a token is seen — e.g. a Cloudflare
45
+ * atomically returns true only the first time a token is seen, e.g. a Cloudflare
46
46
  * KV wrapper; defaults to in-isolate memory.
47
47
  */
48
48
  export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
@@ -51,7 +51,7 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
51
51
  const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
52
52
  const used = store || {
53
53
  // Atomic single-use claim. The body is synchronous (no await), so two
54
- // concurrent claims of the same token cannot both win within an isolate —
54
+ // concurrent claims of the same token cannot both win within an isolate,
55
55
  // closing the TOCTOU that a separate has()+add() would open.
56
56
  claim: (k, exp) => {
57
57
  const e = mem.get(k);
@@ -102,7 +102,7 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
102
102
  if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
103
103
  // Atomically claim single-use only AFTER the work is validated, so an invalid
104
104
  // attempt never consumes the token and concurrent dupes can't both pass.
105
- // F05: FAIL CLOSED — if the claim store is unavailable, deny rather than let
105
+ // F05: FAIL CLOSED, if the claim store is unavailable, deny rather than let
106
106
  // the exception propagate (or, worse, grant). Strict single-use needs an
107
107
  // atomic store (Durable Object); see worker.js durableObjectStore + the
108
108
  // wrangler template. A get-then-put KV store is best-effort only.
@@ -159,7 +159,7 @@ export function createEdgeTollbooth(config = {}) {
159
159
  // operators run the gate on a live site for a week before flipping on enforcement.
160
160
  observe = false,
161
161
  // Pluggable durable-stats sink. Default = in-memory (per-isolate, so on the
162
- // edge it dies with the isolate — pass kvStatsSink(env.TOLLBOOTH_KV) for
162
+ // edge it dies with the isolate, pass kvStatsSink(env.TOLLBOOTH_KV) for
163
163
  // cross-isolate aggregation).
164
164
  statsSink,
165
165
  message = "This resource charges automated / AI clients per request. Humans browse free; bots pay in USDC via x402 or by solving a proof-of-work.",
@@ -181,7 +181,7 @@ export function createEdgeTollbooth(config = {}) {
181
181
  const mem = memorySink();
182
182
  const sink = statsSink || mem;
183
183
  const writeThrough = statsSink && statsSink !== mem;
184
- // Stats must never break a request path — a buggy custom sink can throw
184
+ // Stats must never break a request path, a buggy custom sink can throw
185
185
  // synchronously; we shrug and continue.
186
186
  const incr = (k, n = 1) => {
187
187
  try { mem.incr(k, n); } catch { /* ignore */ }
@@ -189,7 +189,7 @@ export function createEdgeTollbooth(config = {}) {
189
189
  };
190
190
 
191
191
  // Heuristic only. A bot can trivially set `User-Agent: Mozilla/5.0 …` and
192
- // `Accept: text/html` to pass — `strict` mode uses this as the lower bound
192
+ // `Accept: text/html` to pass, `strict` mode uses this as the lower bound
193
193
  // (charge anything that does NOT look like a browser), not as proof of
194
194
  // humanity. For hard guarantees use mode:"all" or `charge:` predicate.
195
195
  const looksHuman = (request) => {
@@ -212,7 +212,7 @@ export function createEdgeTollbooth(config = {}) {
212
212
  if (!shouldCharge(request)) { incr("freeAllowed"); return null; }
213
213
  if (observe) { incr("wouldCharge"); return null; }
214
214
  const u = new URL(request.url);
215
- // F18: bind the canonical ORIGIN, not just path+query — otherwise a proof
215
+ // F18: bind the canonical ORIGIN, not just path+query, otherwise a proof
216
216
  // for /x transfers to any other site sharing the secret. On the edge the
217
217
  // request URL already carries the real origin; prefer an explicit
218
218
  // resourceBaseUrl when configured.
@@ -285,12 +285,12 @@ export function createEdgeTollbooth(config = {}) {
285
285
  }
286
286
 
287
287
  // Operator surface: in-process mirror (sync), durable snapshot (async),
288
- // and an explicit flush() for edge runtimes — the Worker should call this
288
+ // and an explicit flush() for edge runtimes, the Worker should call this
289
289
  // inside ctx.waitUntil so buffered KV writes actually happen.
290
290
  gate.observe = observe;
291
291
  gate.stats = () => ({ ...mem.snapshot(), observe });
292
292
  gate.snapshot = async () => ({ ...(await sink.snapshot()), observe });
293
- // flush() is typically wired to ctx.waitUntil — swallow errors so a sink
293
+ // flush() is typically wired to ctx.waitUntil, swallow errors so a sink
294
294
  // outage can't surface as an unhandled rejection after the response.
295
295
  gate.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
296
296
  return gate;
package/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // agent402-tollbooth — an open-source, self-hostable x402 "pay-per-crawl" gate.
2
+ // agent402-tollbooth: an open-source, self-hostable x402 "pay-per-crawl" gate.
3
3
  //
4
4
  // Put it in front of any site or API: human visitors pass through free, while
5
- // AI crawlers / agents must pay per request — either in USDC over the x402
5
+ // AI crawlers / agents must pay per request, either in USDC over the x402
6
6
  // protocol, or for free by solving a proof-of-work (no wallet, no signup, no
7
7
  // Stripe, no Cloudflare). Use it two ways:
8
8
  //
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // The proof-of-work rail works out of the box with zero configuration. To also
13
13
  // accept USDC, set `payTo` and supply `verifyX402` (wire it to the standard
14
- // x402 server middleware / your facilitator — see README).
14
+ // x402 server middleware / your facilitator, see README).
15
15
  import { realpathSync } from "node:fs";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import { Readable } from "node:stream";
@@ -34,7 +34,7 @@ const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_
34
34
  // P1.5 / FR4-10: a first-party `verifyX402` built from a standard @x402/express
35
35
  // payment middleware, that OWNS timeout + cancellation. The gate calls
36
36
  // verifyX402(req, opts) and puts an AbortSignal on `opts.signal` (aborted when
37
- // the gate's own verify timeout fires); this wrapper honors it — once the signal
37
+ // the gate's own verify timeout fires); this wrapper honors it, once the signal
38
38
  // aborts (or its own optional backstop timeout fires) it settles the verify
39
39
  // promise immediately and reports NOT-verified, so a slow middleware can't leave
40
40
  // the gate hanging or produce a charged-then-denied result after the gate has
@@ -170,9 +170,9 @@ export function createTollbooth(config = {}) {
170
170
  replayStore,
171
171
  botUserAgents = AI_BOTS,
172
172
  // Who pays. Default "bots" = the original behavior (charge AI crawler UAs).
173
- // "all" — charge every client except a `free()` match (UA detection is
173
+ // "all": charge every client except a `free()` match (UA detection is
174
174
  // not a security boundary; this stops relying on it).
175
- // "strict" — charge anything that isn't a real-browser request (browser-like
175
+ // "strict": charge anything that isn't a real-browser request (browser-like
176
176
  // UA + an HTML Accept). Raises the bar on naive scrapers.
177
177
  // An explicit charge()/free() still wins over the mode.
178
178
  mode = process.env.TOLLBOOTH_MODE || "bots",
@@ -195,7 +195,7 @@ export function createTollbooth(config = {}) {
195
195
  resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
196
196
  // Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
197
197
  // high-volume abuse pays escalating CPU regardless of how it disguises itself.
198
- // Off by default — behavior is unchanged unless explicitly enabled.
198
+ // Off by default, behavior is unchanged unless explicitly enabled.
199
199
  adaptive = config.adaptive ?? (process.env.TOLLBOOTH_ADAPTIVE === "true"),
200
200
  adaptivePerBit = Number(process.env.TOLLBOOTH_ADAPTIVE_PER_BIT) || 300, // +1 bit per N charged req/min
201
201
  maxDifficulty,
@@ -243,14 +243,14 @@ export function createTollbooth(config = {}) {
243
243
  return true;
244
244
  };
245
245
 
246
- // Passive analytics — never affects request handling, just counts what happens.
246
+ // Passive analytics, never affects request handling, just counts what happens.
247
247
  // `mem` is an always-on in-process mirror so `.stats()` stays synchronous for
248
248
  // single-process Node deployments. A durable `statsSink` (KV/HTTP) is written
249
249
  // through alongside and is the source of truth for `.snapshot()`.
250
250
  const mem = memorySink();
251
251
  const sink = statsSink || mem;
252
252
  const writeThrough = statsSink && statsSink !== mem;
253
- // Never let a buggy custom sink throw inside the request path — stats are
253
+ // Never let a buggy custom sink throw inside the request path, stats are
254
254
  // non-critical and must not be able to break a payment decision.
255
255
  const incr = (k, n = 1) => {
256
256
  try { mem.incr(k, n); } catch { /* ignore */ }
@@ -396,7 +396,7 @@ export function createTollbooth(config = {}) {
396
396
  // live 2026-08-20: a yParity-style v byte the node normalizes makes
397
397
  // the relay's post-broadcast hash check fail a payment that SETTLED;
398
398
  // the buyer retried into a double charge). Ask the chain whether this
399
- // credential's own transaction landed before discarding the response —
399
+ // credential's own transaction landed before discarding the response,
400
400
  // exact txid derivation from the signed bytes, verification never a
401
401
  // re-broadcast, fails closed to the 402. See tempo.js.
402
402
  const check = tempoCfg.confirmSettlement || ((c) => confirmTempoSettlement(c, { rpcUrl: tempoCfg.confirmRpcUrl, fetchImpl: tempoCfg.fetch }));
@@ -602,7 +602,7 @@ export function createTollbooth(config = {}) {
602
602
  // .stats() is sync (in-process mirror). .snapshot() is async (durable sink).
603
603
  tollbooth.stats = () => ({ ...mem.snapshot(), difficultyNow: difficultyNow(), observe });
604
604
  tollbooth.snapshot = async () => ({ ...(await sink.snapshot()), difficultyNow: difficultyNow(), observe });
605
- // Swallow flush errors — flush() is typically wired to ctx.waitUntil on the
605
+ // Swallow flush errors, flush() is typically wired to ctx.waitUntil on the
606
606
  // edge; an unhandled rejection there pollutes logs without affecting the
607
607
  // already-sent response.
608
608
  tollbooth.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
@@ -617,7 +617,7 @@ export function createProxy(upstream, { maxBody = 10 * 1024 * 1024 } = {}) {
617
617
  return async (req, res) => {
618
618
  try {
619
619
  // Take ONLY the path+query from the (possibly hostile) target; the
620
- // authority is always the operator's upstream — protocol-relative or
620
+ // authority is always the operator's upstream, protocol-relative or
621
621
  // absolute-form targets cannot redirect us to another host.
622
622
  const reqUrl = new URL(req.originalUrl || req.url || "/", base);
623
623
  const target = new URL(reqUrl.pathname + reqUrl.search, base);
@@ -769,7 +769,7 @@ async function startCli() {
769
769
  const port = rawPort !== "" && Number.isInteger(Number(rawPort)) && Number(rawPort) >= 0 ? Number(rawPort) : 4021;
770
770
  const _secret = process.env.TOLLBOOTH_SECRET;
771
771
  if (!_secret) {
772
- console.warn("⚠ TOLLBOOTH_SECRET not set — proof-of-work tokens use a random per-process secret: they won't survive a restart and will be rejected across multiple workers/instances. Set a stable TOLLBOOTH_SECRET in production.");
772
+ console.warn("⚠ TOLLBOOTH_SECRET not set: proof-of-work tokens use a random per-process secret: they won't survive a restart and will be rejected across multiple workers/instances. Set a stable TOLLBOOTH_SECRET in production.");
773
773
  } else if (/^change-me/i.test(_secret) || _secret === "change-me-to-a-long-random-string") {
774
774
  // FATAL, not a warning. The placeholder is published in our own deploy
775
775
  // template, so anyone can read it, mint a token with difficulty 0 and walk
@@ -834,18 +834,18 @@ async function startCli() {
834
834
  const app = express();
835
835
  const x402mw = await buildCliX402Middleware();
836
836
  const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "", replayStore, ...(x402mw ? { x402: x402mw } : {}) });
837
- // Operator analytics — aggregate counts only (no per-request data), mounted
837
+ // Operator analytics, aggregate counts only (no per-request data), mounted
838
838
  // before the gate so they're always reachable and never themselves charged.
839
839
  // Two opt-in admin tokens (legacy `TOLLBOOTH_STATS_TOKEN` covers /stats only;
840
840
  // `TOLLBOOTH_ADMIN_TOKEN` covers both the HTML dashboard AND /stats). If
841
- // neither is set the surfaces remain public (aggregate counts only — current
841
+ // neither is set the surfaces remain public (aggregate counts only, current
842
842
  // behavior) so existing deploys don't break. Comparison is timing-safe.
843
843
  const { dashboardHtml } = await import("./dashboard.js");
844
844
  const { timingSafeEqual } = await import("node:crypto");
845
845
  const ADMIN_TOKEN = process.env.TOLLBOOTH_ADMIN_TOKEN || "";
846
846
  const STATS_TOKEN = process.env.TOLLBOOTH_STATS_TOKEN || "";
847
847
  if (!ADMIN_TOKEN) {
848
- console.warn("⚠ TOLLBOOTH_ADMIN_TOKEN not set — the /__tollbooth analytics dashboard is publicly reachable (aggregate counts only, no per-request/payer data). Set TOLLBOOTH_ADMIN_TOKEN to require a token.");
848
+ console.warn("⚠ TOLLBOOTH_ADMIN_TOKEN not set: the /__tollbooth analytics dashboard is publicly reachable (aggregate counts only, no per-request/payer data). Set TOLLBOOTH_ADMIN_TOKEN to require a token.");
849
849
  }
850
850
  const presented = (req) => {
851
851
  const auth = req.headers["authorization"];
@@ -855,7 +855,7 @@ async function startCli() {
855
855
  return "";
856
856
  };
857
857
  // tokenMatch returns true only on a real, timing-safe equal match. An empty
858
- // `expected` is treated as "no rule configured", NOT as a wildcard — callers
858
+ // `expected` is treated as "no rule configured", NOT as a wildcard, callers
859
859
  // upstream decide whether to invoke this check at all.
860
860
  const tokenMatch = (expected, got) => {
861
861
  if (!expected || typeof got !== "string" || got.length !== expected.length) return false;
@@ -894,7 +894,7 @@ async function startCli() {
894
894
  const rails = [gate.pow ? "proof-of-work" : "", paidLabel].filter(Boolean).join(" + ");
895
895
  // The BOUND port, so PORT=0 (let the OS pick) prints something a caller can use.
896
896
  const bound = server.address()?.port ?? port;
897
- console.log(`agent402-tollbooth listening on :${bound} — charging AI bots via ${rails || "proof-of-work"}`);
897
+ console.log(`agent402-tollbooth listening on :${bound}, charging AI bots via ${rails || "proof-of-work"}`);
898
898
  if (upstream) console.log(` proxying → ${upstream}`);
899
899
  });
900
900
  // Evidence for a process that leaves without being told to. Four times
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Agent402 tollbooth: open-source, self-hostable pay-per-crawl for x402 AND MPP (the sell side of Agentic Finance): charge AI bots per request (USDC via x402, or MPP - the Machine Payments Protocol - through the same settlement, or USDG on Robinhood Chain, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy that settles both wires from env alone - no Cloudflare, no Stripe, no signup.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/pow.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Self-contained proof-of-work for the tollbooth: a no-wallet payment rail.
2
- // A client without USDC proves it spent real CPU instead — the same scheme
2
+ // A client without USDC proves it spent real CPU instead, the same scheme
3
3
  // Agent402 uses, packaged standalone so the tollbooth has zero crypto deps.
4
4
  //
5
5
  // Tokens are HMAC-signed by this process (stateless to issue), bound to the
package/sinks.js CHANGED
@@ -1,17 +1,17 @@
1
- // Pluggable stats sinks for the tollbooth — turn the in-memory counter into
1
+ // Pluggable stats sinks for the tollbooth, turn the in-memory counter into
2
2
  // something that survives restart and aggregates across processes / isolates.
3
3
  //
4
4
  // A sink is just:
5
5
  // {
6
6
  // incr(field, n=1) // fire-and-forget; sync from the gate's perspective
7
- // flush() // optional — run at end of request via ctx.waitUntil
7
+ // flush() // optional, run at end of request via ctx.waitUntil
8
8
  // snapshot() // returns the aggregated counts as { field: number, ... }
9
9
  // }
10
10
  //
11
11
  // Shipped sinks:
12
- // memorySink() — default, in-process, zero config.
13
- // kvStatsSink(kv, opts) — Cloudflare KV (append-only, eventually consistent).
14
- // httpStatsSink(url, opts) — POST batched deltas to any collector.
12
+ // memorySink(): default, in-process, zero config.
13
+ // kvStatsSink(kv, opts), Cloudflare KV (append-only, eventually consistent).
14
+ // httpStatsSink(url, opts), POST batched deltas to any collector.
15
15
  //
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`.
@@ -22,7 +22,7 @@ const ZERO = () => Object.fromEntries(KNOWN_FIELDS.map((k) => [k, 0]));
22
22
  // Filter an untrusted JSON blob (KV value or HTTP collector response) to only
23
23
  // the known numeric counters. Prevents a compromised store from injecting
24
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
25
+ // dashboard.js) might treat as HTML. Defense in depth, the dashboard also
26
26
  // coerces, but trim at the boundary.
27
27
  const sanitize = (obj) => {
28
28
  const out = ZERO();
@@ -56,7 +56,7 @@ export function memorySink() {
56
56
  * delta becomes a KV write. Without that, deltas stay in isolate memory
57
57
  * and die with the isolate.
58
58
  * - KV has no atomic increment, so we never RMW a shared counter. Each
59
- * flush writes a brand-new key (unique id + ts) — concurrent isolates
59
+ * flush writes a brand-new key (unique id + ts), concurrent isolates
60
60
  * can't lose each other's deltas. Snapshot sums every key under the prefix.
61
61
  * - Tradeoff: snapshot reads list+get every key, which is fine for low traffic
62
62
  * (deltas batched per request) but expensive for very high traffic. For
@@ -105,7 +105,7 @@ export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24
105
105
 
106
106
  /**
107
107
  * HTTP-backed sink. Batched POSTs of `{ incr: { field: n, ... }, ts }`.
108
- * GET on the same URL returns the aggregated snapshot — wire that up on your
108
+ * GET on the same URL returns the aggregated snapshot, wire that up on your
109
109
  * collector (a tiny Vercel route hander, an Express app, anything).
110
110
  *
111
111
  * incr(): buffers locally and schedules a flush via setTimeout (Node) or
@@ -163,7 +163,7 @@ export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl, allowInse
163
163
  signal: AbortSignal.timeout(5000),
164
164
  });
165
165
  if (!r.ok) return ZERO();
166
- // Sanitize at the trust boundary — a compromised or misconfigured
166
+ // Sanitize at the trust boundary, a compromised or misconfigured
167
167
  // collector cannot inject non-numeric values or extra keys that the
168
168
  // dashboard would render as HTML.
169
169
  return sanitize(await r.json());
package/tempo.js CHANGED
@@ -248,7 +248,7 @@ export function tempoProblem(kind, detail) {
248
248
  }
249
249
 
250
250
  // ---------------------------------------------------------------------------
251
- // Chain-truth confirm on broadcast failure (2026-08-20) — the relay's verdict
251
+ // Chain-truth confirm on broadcast failure (2026-08-20), the relay's verdict
252
252
  // and the chain's truth can diverge: a buyer whose packed signature ends with
253
253
  // a yParity-style v byte (0x00/0x01) gets normalized to 27/28 by the Tempo
254
254
  // node, so the canonical txid stops matching keccak(submitted bytes) and the
@@ -256,9 +256,9 @@ export function tempoProblem(kind, detail) {
256
256
  // that SETTLED (measured live: two double charges in one buy attempt).
257
257
  // Before answering 402, ask the chain whether this credential's OWN
258
258
  // transaction landed. The txid commits to the signed bytes, so the candidates
259
- // are exact — the submitted form and its v-swapped twin — never a time-window
259
+ // are exact, the submitted form and its v-swapped twin, never a time-window
260
260
  // or payer heuristic. Fails closed on every uncertainty. Dependency-free like
261
- // the rest of this file: keccak-256 is implemented below (BigInt lanes — it
261
+ // the rest of this file: keccak-256 is implemented below (BigInt lanes, it
262
262
  // runs at most once per FAILED broadcast, so speed is irrelevant) and pinned
263
263
  // against standard vectors plus the live incident transaction in the tests.
264
264
  // ---------------------------------------------------------------------------
@@ -290,7 +290,7 @@ function keccakF(A) {
290
290
  }
291
291
 
292
292
  /** keccak-256 of a Buffer, as a 0x hex string. Original Keccak padding
293
- * (0x01 … 0x80), NOT SHA-3's 0x06 — Ethereum txids use the former. */
293
+ * (0x01 … 0x80), NOT SHA-3's 0x06, Ethereum txids use the former. */
294
294
  export function keccak256Hex(buf) {
295
295
  const rate = 136;
296
296
  const padded = Buffer.alloc(Math.ceil((buf.length + 1) / rate) * rate);
@@ -309,7 +309,7 @@ export function keccak256Hex(buf) {
309
309
 
310
310
  /** The txids this signed Tempo transaction could have landed under: the
311
311
  * submitted bytes, plus (when the envelope verifiably ends with the 65-byte
312
- * packed signature — RLP string header 0xb841 at the right offset) the
312
+ * packed signature, RLP string header 0xb841 at the right offset) the
313
313
  * v-swapped twin: yParity (0/1) <-> legacy (27/28). */
314
314
  export function candidateTxIds(signedTx) {
315
315
  const hex = String(signedTx || "").toLowerCase();
package/worker.js CHANGED
@@ -1,4 +1,4 @@
1
- // Cloudflare Worker entry for the tollbooth — open-source pay-per-crawl on the
1
+ // Cloudflare Worker entry for the tollbooth, open-source pay-per-crawl on the
2
2
  // edge. Deploy in front of your origin: humans pass through, AI crawlers pay
3
3
  // (USDC via x402, or free proof-of-work).
4
4
  //
@@ -16,7 +16,7 @@
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
19
+ // Constant-time string compare, Cloudflare Workers don't ship node:crypto's
20
20
  // timingSafeEqual. Short-circuiting `===` on a secret token leaks length and
21
21
  // prefix bits to a sufficiently patient attacker; this doesn't.
22
22
  function constEq(a, b) {
@@ -29,7 +29,7 @@ function constEq(a, b) {
29
29
 
30
30
  const kvStore = (kv) => ({
31
31
  // Best-effort claim. KV has no native compare-and-set, so this is get-then-put
32
- // (eventually consistent) — concurrent dupes across isolates/locations can
32
+ // (eventually consistent), concurrent dupes across isolates/locations can
33
33
  // both observe "unseen" and both pass. For STRICT single-use use the Durable
34
34
  // Object store below. Returns true only the first time a token is seen.
35
35
  claim: async (k, expMs) => {
@@ -40,7 +40,7 @@ const kvStore = (kv) => ({
40
40
  });
41
41
 
42
42
  // F05: strict single-use replay claim via a Durable Object. A DO instance is
43
- // single-threaded — every request addressed to one DO id is serialized — so the
43
+ // single-threaded: every request addressed to one DO id is serialized, so the
44
44
  // get-then-put INSIDE it is ATOMIC across all isolates and locations (KV is
45
45
  // eventually consistent and has no compare-and-set). Each token is routed to its
46
46
  // own DO id, so claims only ever contend on the exact token they concern.
@@ -52,7 +52,7 @@ export class TollboothReplay {
52
52
  const already = await this.state.storage.get("claimed");
53
53
  if (already != null) return Response.json({ granted: false });
54
54
  await this.state.storage.put("claimed", 1);
55
- // Auto-expire the DO storage at the token's expiry — the token is worthless
55
+ // Auto-expire the DO storage at the token's expiry, the token is worthless
56
56
  // after that, so the claim record can be dropped and storage stays bounded.
57
57
  if (expMs > Date.now()) { try { await this.state.storage.setAlarm(expMs); } catch { /* */ } }
58
58
  return Response.json({ granted: true });
@@ -82,7 +82,7 @@ export default {
82
82
  // FR4-04: FAIL CLOSED in enforcing mode without an ATOMIC replay store. A
83
83
  // Durable Object (TOLLBOOTH_REPLAY) gives strict single-use across isolates
84
84
  // and locations. KV is eventually consistent (get-then-put, NOT atomic), so
85
- // concurrent duplicate PoW solutions can both pass — a warning does not
85
+ // concurrent duplicate PoW solutions can both pass, a warning does not
86
86
  // protect a production deployment. Refuse to enforce on a non-atomic (or
87
87
  // absent) store unless the operator sets an explicit, loud insecure override.
88
88
  const allowNonAtomic = env.TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY === "true";
@@ -97,12 +97,12 @@ export default {
97
97
  replayStore = durableObjectStore(env.TOLLBOOTH_REPLAY);
98
98
  } else if (env.TOLLBOOTH_KV) {
99
99
  replayStore = kvStore(env.TOLLBOOTH_KV);
100
- if (enforcing) console.warn("agent402-tollbooth: ENFORCING on eventually-consistent KV via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override (get-then-put is NOT atomic — concurrent duplicate solutions across isolates/locations can both pass). Bind a Durable Object as TOLLBOOTH_REPLAY for strict single-use.");
100
+ if (enforcing) console.warn("agent402-tollbooth: ENFORCING on eventually-consistent KV via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override (get-then-put is NOT atomic: concurrent duplicate solutions across isolates/locations can both pass). Bind a Durable Object as TOLLBOOTH_REPLAY for strict single-use.");
101
101
  } else if (enforcing) {
102
- console.warn("agent402-tollbooth: ENFORCING with NO replay store via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override — proof-of-work replay protection is per-isolate only. Bind a Durable Object for production.");
102
+ console.warn("agent402-tollbooth: ENFORCING with NO replay store via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override: proof-of-work replay protection is per-isolate only. Bind a Durable Object for production.");
103
103
  }
104
104
  // Durable stats live in KV if a namespace is bound. Without it, the dashboard
105
- // is per-isolate (dies on cold start) — fine for dev, useless for prod.
105
+ // is per-isolate (dies on cold start), fine for dev, useless for prod.
106
106
  const statsSink = env.TOLLBOOTH_KV
107
107
  ? kvStatsSink(env.TOLLBOOTH_KV, { bucket: env.TOLLBOOTH_STATS_BUCKET || "default" })
108
108
  : undefined;
@@ -119,14 +119,14 @@ export default {
119
119
  });
120
120
 
121
121
  // Free, never-gated operator endpoints. Mounted BEFORE the gate so they
122
- // can't be paywalled — and so the dashboard polls work even when the rest
122
+ // can't be paywalled, and so the dashboard polls work even when the rest
123
123
  // of the origin is fully gated.
124
124
  const u = new URL(request.url);
125
125
  if (u.pathname === "/__tollbooth" || u.pathname === "/__tollbooth/") {
126
126
  return new Response(dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
127
127
  }
128
128
  if (u.pathname === "/__tollbooth/stats") {
129
- // Optional bearer-token gate — share with your monitoring caller. We
129
+ // Optional bearer-token gate, share with your monitoring caller. We
130
130
  // recommend setting this in any prod deploy: without it, anyone on the
131
131
  // internet can read aggregate counts (no per-request data, but still
132
132
  // potentially sensitive competitive info).
@@ -139,7 +139,7 @@ export default {
139
139
  }
140
140
 
141
141
  const blocked = await gate(request);
142
- // Flush any buffered stats to KV after we've replied — survives the response.
142
+ // Flush any buffered stats to KV after we've replied, survives the response.
143
143
  if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(gate.flush());
144
144
  if (blocked) return blocked;
145
145