agent402-tollbooth 0.10.4 → 0.10.6
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 +1 -1
- package/dashboard.js +20 -20
- package/demo.js +6 -6
- package/edge.js +10 -10
- package/index.js +19 -19
- package/package.json +2 -2
- package/pow.js +1 -1
- package/sinks.js +9 -9
- package/tempo.js +5 -5
- package/worker.js +12 -12
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
63
|
-
<span>peak<b id="ratepeak"
|
|
64
|
-
<span class="paid">paid<b id="paidnow"
|
|
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"
|
|
80
|
-
<div class="card"><div class="k">Paid conversion</div><div class="v accent" id="paidpct"
|
|
81
|
-
<div class="card"><div class="k">Paid in USDC</div><div class="v accent" id="usdcpct"
|
|
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
|
|
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 <origin>/__tollbooth/stats | jq</pre>
|
|
89
89
|
</div>
|
|
90
90
|
<div class="probe">
|
|
91
|
-
<div class="label"><span>Probe as a known bot
|
|
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' <origin>/</pre>
|
|
93
93
|
</div>
|
|
94
94
|
<div class="probe">
|
|
95
|
-
<div class="label"><span>Probe as a browser
|
|
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' <origin>/</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' <origin>/ | jq .</pre>
|
|
101
101
|
</div>
|
|
102
102
|
<div class="probe">
|
|
103
|
-
<div class="label"><span>KPI grep
|
|
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 <origin>/__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
|
|
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
|
|
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
|
|
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({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
55
|
+
log(`${c.bold}${c.green}✓ Pay-per-crawl, end to end${c.reset}: humans free, bots pay (USDC or compute), settled to your own wallet with no signup.`);
|
|
56
56
|
} finally {
|
|
57
57
|
server.close();
|
|
58
58
|
}
|
package/edge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Edge / runtime-agnostic tollbooth
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// agent402-tollbooth
|
|
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
|
|
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
|
+
// account). Use it two ways:
|
|
8
8
|
//
|
|
9
9
|
// 1. Express middleware: app.use(createTollbooth({ ... }))
|
|
10
10
|
// 2. Reverse proxy (CLI): TOLLBOOTH_UPSTREAM=https://your-site.com npx agent402-tollbooth
|
|
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
|
|
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
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.10.
|
|
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
|
|
3
|
+
"version": "0.10.6",
|
|
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, on your own server with no account or signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
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
|
|
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
|
|
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
|
|
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()
|
|
13
|
-
// kvStatsSink(kv, opts)
|
|
14
|
-
// httpStatsSink(url, opts)
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|