agent402-tollbooth 0.1.4 → 0.3.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 +138 -19
- package/dashboard.js +61 -0
- package/edge.js +42 -5
- package/index.js +76 -5
- package/package.json +11 -3
- package/pow.js +8 -5
- package/sinks.js +148 -0
- package/worker.js +32 -2
package/README.md
CHANGED
|
@@ -92,27 +92,41 @@ The same gate is also built on the Web Crypto + Fetch APIs (`edge.js`), so it ru
|
|
|
92
92
|
anywhere — no Node required. The gate returns a `402 Response` when the client
|
|
93
93
|
must pay, or `null` to let it through.
|
|
94
94
|
|
|
95
|
-
**
|
|
95
|
+
**Ready-to-deploy templates** (copy a folder, don't assemble from docs):
|
|
96
|
+
|
|
97
|
+
- **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/) — a ready
|
|
98
|
+
`wrangler.toml` + a 3-step deploy guide (the open pay-per-crawl, on the
|
|
99
|
+
incumbent's own platform).
|
|
100
|
+
- **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/) — a drop-in
|
|
101
|
+
`middleware.js` + a 3-step deploy guide.
|
|
102
|
+
- **Docker** → [`deploy/docker/`](deploy/docker/) — a `Dockerfile` +
|
|
103
|
+
`docker-compose.yml` to run the reverse proxy in front of any site with
|
|
104
|
+
`docker compose up -d` (includes the live `/__tollbooth` dashboard).
|
|
105
|
+
|
|
106
|
+
The short version of each:
|
|
96
107
|
|
|
97
108
|
```toml
|
|
109
|
+
# wrangler.toml (full template: deploy/cloudflare/wrangler.toml)
|
|
98
110
|
name = "tollbooth"
|
|
99
111
|
main = "node_modules/agent402-tollbooth/worker.js"
|
|
112
|
+
compatibility_date = "2026-01-01"
|
|
100
113
|
[vars]
|
|
101
114
|
TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
|
|
102
115
|
TOLLBOOTH_PAYTO = "0xYourWallet" # optional: advertise a USDC x402 quote
|
|
103
|
-
# wrangler secret put TOLLBOOTH_SECRET
|
|
116
|
+
# npx wrangler secret put TOLLBOOTH_SECRET
|
|
104
117
|
# optional single-use replay store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
|
|
105
118
|
```
|
|
106
119
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
```js
|
|
121
|
+
// middleware.js (full template: deploy/nextjs/middleware.js)
|
|
122
|
+
import { NextResponse } from "next/server";
|
|
110
123
|
import { createEdgeTollbooth } from "agent402-tollbooth/edge";
|
|
111
|
-
const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET
|
|
124
|
+
const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET });
|
|
112
125
|
|
|
113
|
-
export async function middleware(req
|
|
126
|
+
export async function middleware(req) {
|
|
114
127
|
return (await gate(req)) ?? NextResponse.next();
|
|
115
128
|
}
|
|
129
|
+
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
116
130
|
```
|
|
117
131
|
|
|
118
132
|
**Any Fetch-API runtime** (Deno, Bun, custom): `const gate = createEdgeTollbooth({ secret }); const blocked = await gate(request); return blocked ?? fetch(request);`
|
|
@@ -152,23 +166,127 @@ app.use(createTollbooth({
|
|
|
152
166
|
| `network` | `"base"` | x402 network |
|
|
153
167
|
| `pow` | `true` | Enable the free proof-of-work rail |
|
|
154
168
|
| `powDifficulty` | `18` | PoW difficulty in leading zero bits (~0.1–0.5s of CPU) |
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
169
|
+
| `mode` | `"bots"` | Who pays: `"bots"` (AI-crawler UAs) · `"all"` (everyone but `free()`) · `"strict"` (anything that isn't a real-browser request) |
|
|
170
|
+
| `adaptive` | `false` | Raise PoW difficulty as charged-request load climbs (anti-abuse under traffic spikes) |
|
|
171
|
+
| `maxDifficulty` | `base+6` | Ceiling for adaptive difficulty |
|
|
172
|
+
| `adaptivePerBit` | `300` | +1 difficulty bit per N charged requests/min |
|
|
173
|
+
| `botUserAgents` | `AI_BOTS` | User-agents to charge in `"bots"` mode |
|
|
174
|
+
| `charge(req)` | mode | Custom "should this client pay?" predicate (wins over `mode`) |
|
|
175
|
+
| `free(req)` | – | Custom force-allow predicate (wins over everything) |
|
|
158
176
|
| `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
|
|
159
177
|
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
178
|
+
| `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
|
|
179
|
+
| `statsSink` | in-memory | Durable stats backend. Built-ins: `memorySink`, `kvStatsSink(kv)`, `httpStatsSink(url)`. |
|
|
160
180
|
|
|
161
181
|
Environment variables: `TOLLBOOTH_UPSTREAM`, `TOLLBOOTH_PAYTO`, `TOLLBOOTH_PRICE`,
|
|
162
|
-
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `
|
|
182
|
+
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_MODE`, `TOLLBOOTH_ADAPTIVE`,
|
|
183
|
+
`TOLLBOOTH_MAX_POW_BITS`, `TOLLBOOTH_ADAPTIVE_PER_BIT`, `TOLLBOOTH_SECRET`,
|
|
184
|
+
`TOLLBOOTH_OBSERVE`, `TOLLBOOTH_STATS_TOKEN`, `TOLLBOOTH_STATS_BUCKET`, `PORT`.
|
|
163
185
|
|
|
164
186
|
## How it decides who pays
|
|
165
187
|
|
|
166
|
-
By default it charges requests whose `User-Agent` matches a known
|
|
167
|
-
crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider,
|
|
168
|
-
Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are
|
|
169
|
-
**not** charged so your SEO indexing stays free.
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
By default (`mode: "bots"`) it charges requests whose `User-Agent` matches a known
|
|
189
|
+
**AI/LLM crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider,
|
|
190
|
+
Google-Extended, Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are
|
|
191
|
+
intentionally **not** charged so your SEO indexing stays free.
|
|
192
|
+
|
|
193
|
+
**Don't want to play whack-a-mole with bot detection?** That's the point of the
|
|
194
|
+
other modes — you stop trying to *identify* bots and instead make access *cost
|
|
195
|
+
something*:
|
|
196
|
+
- `mode: "all"` charges every client (except a `free()` match). A "more
|
|
197
|
+
sophisticated" bot gains nothing by disguising itself — everyone pays or solves
|
|
198
|
+
a proof-of-work.
|
|
199
|
+
- `mode: "strict"` charges anything that isn't a real-browser request (browser-like
|
|
200
|
+
UA **and** an HTML `Accept`), letting genuine human page-loads through free.
|
|
201
|
+
- `adaptive: true` makes proof-of-work **harder as load climbs**, so a high-volume
|
|
202
|
+
scraper pays escalating CPU per request regardless of how it looks — detection is
|
|
203
|
+
cat-and-mouse, economics isn't.
|
|
204
|
+
|
|
205
|
+
## Observe before charging
|
|
206
|
+
|
|
207
|
+
Don't want to flip a meter on cold? **Run the gate in observe-only mode for a
|
|
208
|
+
week first** — every request is still classified (bot vs. human) and counted,
|
|
209
|
+
but nothing ever gets a 402:
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
app.use(createTollbooth({ observe: true })); // or: TOLLBOOTH_OBSERVE=true
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
On the edge / Cloudflare Worker / Next.js: set `TOLLBOOTH_OBSERVE=true` in env.
|
|
216
|
+
|
|
217
|
+
The dashboard grows a **"Would charge"** counter so you can show your team —
|
|
218
|
+
or your client — exactly how much of their traffic is AI bots **before** you
|
|
219
|
+
start returning 402s. Removing the flag flips on enforcement with no other
|
|
220
|
+
changes. Bots see a `X-Tollbooth-Observed: would-charge` header in observe mode
|
|
221
|
+
(handy for log filtering); humans see nothing.
|
|
222
|
+
|
|
223
|
+
## Analytics
|
|
224
|
+
|
|
225
|
+
The middleware keeps aggregate counters (no per-request data):
|
|
226
|
+
- `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, difficultyNow, observe }`.
|
|
227
|
+
- `gate.snapshot()` → async, reads from the configured durable sink (defaults to memory).
|
|
228
|
+
- `gate.flush()` → flush any buffered deltas to the durable sink (call inside `ctx.waitUntil` on edge runtimes).
|
|
229
|
+
|
|
230
|
+
The reverse-proxy CLI exposes them as JSON at **`/__tollbooth/stats`** and as a
|
|
231
|
+
live **dashboard at `/__tollbooth`** — requests, how many were charged,
|
|
232
|
+
proof-of-work solves, USDC collected, and what share of your traffic is bots.
|
|
233
|
+
|
|
234
|
+
## Durable stats (survive restart, aggregate across instances)
|
|
235
|
+
|
|
236
|
+
By default, stats live in process memory — fine for single-instance Node,
|
|
237
|
+
useless across multiple replicas or on the edge. Pass a `statsSink` to make
|
|
238
|
+
them survive:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
// Cloudflare Workers: aggregate across all isolates using the same KV namespace
|
|
242
|
+
// that holds the PoW single-use store.
|
|
243
|
+
import { createEdgeTollbooth, kvStatsSink } from "agent402-tollbooth/edge";
|
|
244
|
+
const gate = createEdgeTollbooth({
|
|
245
|
+
secret: env.TOLLBOOTH_SECRET,
|
|
246
|
+
statsSink: kvStatsSink(env.TOLLBOOTH_KV, { bucket: "default" }),
|
|
247
|
+
});
|
|
248
|
+
// inside fetch():
|
|
249
|
+
ctx.waitUntil(gate.flush()); // make sure deltas land in KV after the response
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
// Any Node deploy: POST batched deltas to a tiny collector (Vercel KV /
|
|
254
|
+
// Upstash / your own API).
|
|
255
|
+
import { createTollbooth, httpStatsSink } from "agent402-tollbooth";
|
|
256
|
+
app.use(createTollbooth({
|
|
257
|
+
statsSink: httpStatsSink(process.env.TOLLBOOTH_STATS_URL, {
|
|
258
|
+
token: process.env.TOLLBOOTH_STATS_TOKEN,
|
|
259
|
+
}),
|
|
260
|
+
}));
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Sink interface (build your own — e.g. a Cloudflare Durable Object for strict
|
|
264
|
+
consistency):
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
type StatsSink = {
|
|
268
|
+
incr(field: string, n?: number): void; // fire-and-forget
|
|
269
|
+
flush?(): Promise<void>; // optional explicit flush
|
|
270
|
+
snapshot(): Promise<Record<string, number>>; // aggregated view
|
|
271
|
+
};
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Edge analytics (Cloudflare Worker / Next.js)
|
|
275
|
+
|
|
276
|
+
The Cloudflare Worker entry (`worker.js`) auto-mounts both the dashboard and
|
|
277
|
+
JSON endpoint, BEFORE the gate so they're never paywalled:
|
|
278
|
+
|
|
279
|
+
- **`/__tollbooth`** → live dashboard
|
|
280
|
+
- **`/__tollbooth/stats`** → JSON snapshot (gate with `TOLLBOOTH_STATS_TOKEN` for bearer-auth)
|
|
281
|
+
|
|
282
|
+
With a `TOLLBOOTH_KV` namespace bound, the stats aggregate across all isolates
|
|
283
|
+
of all Cloudflare colos serving the Worker — one consistent view.
|
|
284
|
+
|
|
285
|
+
On Next.js / Vercel Edge, middleware can't mount dashboards itself (it'd gate
|
|
286
|
+
them), so a companion **route handler** at `app/__tollbooth/stats/route.js`
|
|
287
|
+
serves the JSON; a static **page** at `app/__tollbooth/page.jsx` renders the
|
|
288
|
+
dashboard HTML. Both are in [`deploy/nextjs/middleware.js`](deploy/nextjs/middleware.js)
|
|
289
|
+
as drop-in copyable snippets.
|
|
172
290
|
|
|
173
291
|
## Production checklist (read this)
|
|
174
292
|
|
|
@@ -183,8 +301,9 @@ without a browser-like `Accept` header, or charge everyone on `/api/*`).
|
|
|
183
301
|
redirect it elsewhere) and **strips client-forged trust/forwarding headers**
|
|
184
302
|
(`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
|
|
185
303
|
- **UA matching is the default, not a security boundary** — a bot can forge a
|
|
186
|
-
human UA to get the *same free access a human gets* (it gains nothing more).
|
|
187
|
-
|
|
304
|
+
human UA to get the *same free access a human gets* (it gains nothing more). To
|
|
305
|
+
stop relying on detection entirely, use `mode: "all"` / `mode: "strict"`, and
|
|
306
|
+
turn on `adaptive` so high-volume abuse pays escalating proof-of-work.
|
|
188
307
|
|
|
189
308
|
## Notes
|
|
190
309
|
|
package/dashboard.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A tiny self-contained operator dashboard for the tollbooth, served at
|
|
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
|
|
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.
|
|
6
|
+
export function dashboardHtml() {
|
|
7
|
+
return `<!doctype html>
|
|
8
|
+
<html lang="en"><head>
|
|
9
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<meta name="robots" content="noindex">
|
|
11
|
+
<title>agent402-tollbooth — live stats</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root{--bg:#0b0e14;--card:#131826;--text:#e6e9f0;--muted:#8b93a7;--accent:#4ade80;--line:#1e2638;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
14
|
+
*{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}
|
|
16
|
+
h1{font-size:1.4rem;display:flex;align-items:center;gap:10px}
|
|
17
|
+
.badge{font:700 14px/1 var(--mono);color:var(--accent);background:#000;border:1px solid #1f4a1d;border-radius:8px;padding:8px 10px}
|
|
18
|
+
.sub{color:var(--muted);margin:6px 0 24px;font-size:.9rem}
|
|
19
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px}
|
|
20
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px}
|
|
21
|
+
.k{color:var(--muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
|
|
22
|
+
.v{font:800 1.8rem/1.1 var(--mono);margin-top:8px}
|
|
23
|
+
.v.accent{color:var(--accent)}
|
|
24
|
+
footer{color:var(--muted);font-size:.8rem;margin-top:28px}
|
|
25
|
+
a{color:var(--accent)}
|
|
26
|
+
</style></head>
|
|
27
|
+
<body><div class="wrap">
|
|
28
|
+
<h1><span class="badge">402</span> agent402-tollbooth <span id="mode"></span></h1>
|
|
29
|
+
<div class="sub">Live pay-per-crawl stats · refreshes every 5s · <span id="since"></span></div>
|
|
30
|
+
<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>
|
|
32
|
+
</div>
|
|
33
|
+
<script>
|
|
34
|
+
const cards=[
|
|
35
|
+
["requests","Requests",false],
|
|
36
|
+
["freeAllowed","Humans (free)",false],
|
|
37
|
+
["wouldCharge","Would charge (observe)","accent"],
|
|
38
|
+
["charged","Charged (402)","accent"],
|
|
39
|
+
["powSolved","Proof-of-work paid","accent"],
|
|
40
|
+
["x402Paid","USDC paid (x402)","accent"],
|
|
41
|
+
["difficultyNow","PoW difficulty",false]
|
|
42
|
+
];
|
|
43
|
+
async function tick(){
|
|
44
|
+
try{
|
|
45
|
+
const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
|
|
46
|
+
document.getElementById("grid").innerHTML=cards.map(function(c){
|
|
47
|
+
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>';
|
|
50
|
+
}).join("");
|
|
51
|
+
var botish=(s.charged||0)+(s.wouldCharge||0);
|
|
52
|
+
var pct=s.requests?Math.round((botish/s.requests)*100):0;
|
|
53
|
+
document.getElementById("botpct").textContent=pct+"%";
|
|
54
|
+
document.getElementById("mode").textContent=s.observe?" · OBSERVE":"";
|
|
55
|
+
if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
|
|
56
|
+
}catch(e){/* keep last values */}
|
|
57
|
+
}
|
|
58
|
+
tick();setInterval(tick,5000);
|
|
59
|
+
</script>
|
|
60
|
+
</body></html>`;
|
|
61
|
+
}
|
package/edge.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// See worker.js for a Cloudflare Worker entry, and the README for Next.js.
|
|
9
9
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
10
|
+
import { memorySink } from "./sinks.js";
|
|
11
|
+
|
|
12
|
+
export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
|
|
10
13
|
|
|
11
14
|
const te = new TextEncoder();
|
|
12
15
|
|
|
@@ -121,34 +124,59 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
121
124
|
secret,
|
|
122
125
|
store,
|
|
123
126
|
botUserAgents = AI_BOTS,
|
|
127
|
+
// "bots" (default, charge AI crawler UAs) | "all" (charge all but free()) |
|
|
128
|
+
// "strict" (charge anything that isn't a real-browser request). An explicit
|
|
129
|
+
// charge()/free() still wins. Default preserves the original behavior.
|
|
130
|
+
mode = "bots",
|
|
124
131
|
charge,
|
|
125
132
|
free,
|
|
126
133
|
resourceBaseUrl = "",
|
|
134
|
+
// Observe-only mode: count what would have been charged but never 402. Lets
|
|
135
|
+
// operators run the gate on a live site for a week before flipping on enforcement.
|
|
136
|
+
observe = false,
|
|
137
|
+
// Pluggable durable-stats sink. Default = in-memory (per-isolate, so on the
|
|
138
|
+
// edge it dies with the isolate — pass kvStatsSink(env.TOLLBOOTH_KV) for
|
|
139
|
+
// cross-isolate aggregation).
|
|
140
|
+
statsSink,
|
|
127
141
|
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.",
|
|
128
142
|
} = config;
|
|
129
143
|
|
|
130
144
|
const isBot = makeBotMatcher(botUserAgents);
|
|
131
145
|
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
146
|
+
const mem = memorySink();
|
|
147
|
+
const sink = statsSink || mem;
|
|
148
|
+
const writeThrough = statsSink && statsSink !== mem;
|
|
149
|
+
const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
|
|
132
150
|
|
|
151
|
+
const looksHuman = (request) => {
|
|
152
|
+
const ua = request.headers.get("user-agent") || "";
|
|
153
|
+
const accept = request.headers.get("accept") || "";
|
|
154
|
+
return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
|
|
155
|
+
};
|
|
133
156
|
const shouldCharge = (request) => {
|
|
134
157
|
try {
|
|
135
158
|
if (typeof free === "function" && free(request)) return false;
|
|
136
159
|
if (typeof charge === "function") return Boolean(charge(request));
|
|
137
160
|
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
138
|
-
|
|
161
|
+
if (mode === "all") return true;
|
|
162
|
+
if (mode === "strict") return !looksHuman(request);
|
|
163
|
+
return isBot(request.headers.get("user-agent") || ""); // "bots" (default)
|
|
139
164
|
};
|
|
140
165
|
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
async function gate(request) {
|
|
167
|
+
incr("requests");
|
|
168
|
+
if (!shouldCharge(request)) { incr("freeAllowed"); return null; }
|
|
169
|
+
if (observe) { incr("wouldCharge"); return null; }
|
|
143
170
|
const u = new URL(request.url);
|
|
144
171
|
const resource = (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + u.pathname + u.search;
|
|
145
172
|
|
|
146
173
|
const sol = request.headers.get("x-pow-solution");
|
|
147
174
|
if (powEngine && sol) {
|
|
148
175
|
const r = await powEngine.verify(sol, resource);
|
|
149
|
-
if (r.ok) return null;
|
|
176
|
+
if (r.ok) { incr("powSolved"); return null; }
|
|
150
177
|
}
|
|
151
178
|
|
|
179
|
+
incr("charged");
|
|
152
180
|
const body = {
|
|
153
181
|
error: "Payment Required",
|
|
154
182
|
message,
|
|
@@ -159,5 +187,14 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
159
187
|
status: 402,
|
|
160
188
|
headers: { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" },
|
|
161
189
|
});
|
|
162
|
-
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Operator surface: in-process mirror (sync), durable snapshot (async),
|
|
193
|
+
// and an explicit flush() for edge runtimes — the Worker should call this
|
|
194
|
+
// inside ctx.waitUntil so buffered KV writes actually happen.
|
|
195
|
+
gate.observe = observe;
|
|
196
|
+
gate.stats = () => ({ ...mem.snapshot(), observe });
|
|
197
|
+
gate.snapshot = async () => ({ ...(await sink.snapshot()), observe });
|
|
198
|
+
gate.flush = () => Promise.resolve(sink.flush && sink.flush());
|
|
199
|
+
return gate;
|
|
163
200
|
}
|
package/index.js
CHANGED
|
@@ -16,9 +16,11 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { Readable } from "node:stream";
|
|
17
17
|
import { createPow } from "./pow.js";
|
|
18
18
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
19
|
+
import { memorySink } from "./sinks.js";
|
|
19
20
|
|
|
20
21
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
21
22
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
23
|
+
export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
|
|
22
24
|
|
|
23
25
|
const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
|
|
24
26
|
// Headers a client must never be able to forge through the proxy: the gate's own
|
|
@@ -44,6 +46,8 @@ const STRIP_INBOUND = new Set([
|
|
|
44
46
|
* @param {(req, requirements)=>boolean|Promise<boolean>} [config.verifyX402] USDC settlement check
|
|
45
47
|
* @param {string} [config.resourceBaseUrl] absolute base for the x402 `resource`/PoW binding
|
|
46
48
|
* @param {string} [config.message] human-readable note included in the 402
|
|
49
|
+
* @param {boolean} [config.observe] observe-only: classify, count, never 402 (deploy a week before enforcing)
|
|
50
|
+
* @param {object} [config.statsSink] pluggable durable-stats sink (default: in-memory). See sinks.js.
|
|
47
51
|
*/
|
|
48
52
|
export function createTollbooth(config = {}) {
|
|
49
53
|
const {
|
|
@@ -55,22 +59,71 @@ export function createTollbooth(config = {}) {
|
|
|
55
59
|
powDifficulty,
|
|
56
60
|
powSecret,
|
|
57
61
|
botUserAgents = AI_BOTS,
|
|
62
|
+
// Who pays. Default "bots" = the original behavior (charge AI crawler UAs).
|
|
63
|
+
// "all" — charge every client except a `free()` match (UA detection is
|
|
64
|
+
// not a security boundary; this stops relying on it).
|
|
65
|
+
// "strict" — charge anything that isn't a real-browser request (browser-like
|
|
66
|
+
// UA + an HTML Accept). Raises the bar on naive scrapers.
|
|
67
|
+
// An explicit charge()/free() still wins over the mode.
|
|
68
|
+
mode = process.env.TOLLBOOTH_MODE || "bots",
|
|
58
69
|
charge,
|
|
59
70
|
free,
|
|
60
71
|
verifyX402,
|
|
61
72
|
resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
|
|
73
|
+
// Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
|
|
74
|
+
// high-volume abuse pays escalating CPU regardless of how it disguises itself.
|
|
75
|
+
// Off by default — behavior is unchanged unless explicitly enabled.
|
|
76
|
+
adaptive = config.adaptive ?? (process.env.TOLLBOOTH_ADAPTIVE === "true"),
|
|
77
|
+
adaptivePerBit = Number(process.env.TOLLBOOTH_ADAPTIVE_PER_BIT) || 300, // +1 bit per N charged req/min
|
|
78
|
+
maxDifficulty,
|
|
79
|
+
// Observe-only mode: classify every request as charge-vs-free and count it,
|
|
80
|
+
// but always let it through (never return 402). Use it to measure a site's
|
|
81
|
+
// bot traffic for a week before flipping the meter on. Off by default.
|
|
82
|
+
observe = config.observe ?? (process.env.TOLLBOOTH_OBSERVE === "true"),
|
|
83
|
+
// Pluggable durable-stats sink. Default = in-memory (current behavior).
|
|
84
|
+
// See sinks.js for kvStatsSink (Cloudflare KV) and httpStatsSink.
|
|
85
|
+
statsSink,
|
|
62
86
|
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.",
|
|
63
87
|
} = config;
|
|
64
88
|
|
|
65
89
|
const isBot = makeBotMatcher(botUserAgents);
|
|
66
90
|
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
67
91
|
|
|
92
|
+
// Passive analytics — never affects request handling, just counts what happens.
|
|
93
|
+
// `mem` is an always-on in-process mirror so `.stats()` stays synchronous for
|
|
94
|
+
// single-process Node deployments. A durable `statsSink` (KV/HTTP) is written
|
|
95
|
+
// through alongside and is the source of truth for `.snapshot()`.
|
|
96
|
+
const mem = memorySink();
|
|
97
|
+
const sink = statsSink || mem;
|
|
98
|
+
const writeThrough = statsSink && statsSink !== mem;
|
|
99
|
+
const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
|
|
100
|
+
|
|
101
|
+
const looksHuman = (req) => {
|
|
102
|
+
const ua = req.headers["user-agent"] || "";
|
|
103
|
+
const accept = req.headers["accept"] || "";
|
|
104
|
+
return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
|
|
105
|
+
};
|
|
68
106
|
const shouldCharge = (req) => {
|
|
69
107
|
try {
|
|
70
108
|
if (typeof free === "function" && free(req)) return false;
|
|
71
109
|
if (typeof charge === "function") return Boolean(charge(req));
|
|
72
110
|
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
73
|
-
|
|
111
|
+
if (mode === "all") return true;
|
|
112
|
+
if (mode === "strict") return !looksHuman(req);
|
|
113
|
+
return isBot(req.headers["user-agent"] || ""); // "bots" (default)
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Sliding-window of recent charged requests → adaptive PoW difficulty.
|
|
117
|
+
const baseDifficulty = powEngine?.difficulty ?? (Number(process.env.TOLLBOOTH_POW_BITS) || 18);
|
|
118
|
+
const ceilDifficulty = Math.min(Number(maxDifficulty) || baseDifficulty + 6, 32);
|
|
119
|
+
const ADAPT_WINDOW_MS = 60_000;
|
|
120
|
+
let chargedWindow = [];
|
|
121
|
+
const difficultyNow = () => {
|
|
122
|
+
if (!adaptive) return baseDifficulty;
|
|
123
|
+
const cut = Date.now() - ADAPT_WINDOW_MS;
|
|
124
|
+
if (chargedWindow.length > 100_000) chargedWindow = chargedWindow.filter((t) => t > cut); // hard bound
|
|
125
|
+
else while (chargedWindow.length && chargedWindow[0] < cut) chargedWindow.shift();
|
|
126
|
+
return Math.min(baseDifficulty + Math.floor(chargedWindow.length / Math.max(1, adaptivePerBit)), ceilDifficulty);
|
|
74
127
|
};
|
|
75
128
|
const resourceOf = (req) => {
|
|
76
129
|
// Canonicalize to path+search (matches the edge impl) so the PoW binding is
|
|
@@ -82,10 +135,16 @@ export function createTollbooth(config = {}) {
|
|
|
82
135
|
};
|
|
83
136
|
|
|
84
137
|
function tollbooth(req, res, next) {
|
|
85
|
-
|
|
138
|
+
incr("requests");
|
|
139
|
+
if (!shouldCharge(req)) { incr("freeAllowed"); return next(); }
|
|
140
|
+
// Observe-only: classify as would-charge but let it through. Lets operators
|
|
141
|
+
// measure bot traffic on a live site before turning on enforcement.
|
|
142
|
+
if (observe) { incr("wouldCharge"); res.setHeader("X-Tollbooth-Observed", "would-charge"); return next(); }
|
|
86
143
|
const resource = resourceOf(req);
|
|
87
144
|
|
|
88
145
|
const send402 = (extra = {}) => {
|
|
146
|
+
incr("charged");
|
|
147
|
+
if (adaptive) chargedWindow.push(Date.now());
|
|
89
148
|
const body = {
|
|
90
149
|
error: "Payment Required",
|
|
91
150
|
message,
|
|
@@ -94,7 +153,7 @@ export function createTollbooth(config = {}) {
|
|
|
94
153
|
: [],
|
|
95
154
|
...extra,
|
|
96
155
|
};
|
|
97
|
-
if (powEngine) body.proofOfWork = powEngine.challenge(resource);
|
|
156
|
+
if (powEngine) body.proofOfWork = powEngine.challenge(resource, difficultyNow());
|
|
98
157
|
res.status(402).json(body);
|
|
99
158
|
};
|
|
100
159
|
|
|
@@ -102,7 +161,7 @@ export function createTollbooth(config = {}) {
|
|
|
102
161
|
const powHeader = req.headers["x-pow-solution"];
|
|
103
162
|
if (powEngine && powHeader) {
|
|
104
163
|
const r = powEngine.verify(powHeader, resource);
|
|
105
|
-
if (r.ok) { res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
164
|
+
if (r.ok) { incr("powSolved"); res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
106
165
|
res.setHeader("X-Pow-Error", r.reason);
|
|
107
166
|
}
|
|
108
167
|
|
|
@@ -114,7 +173,7 @@ export function createTollbooth(config = {}) {
|
|
|
114
173
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
|
|
115
174
|
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
|
|
116
175
|
.then((ok) => {
|
|
117
|
-
if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
176
|
+
if (ok) { incr("x402Paid"); res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
118
177
|
send402();
|
|
119
178
|
})
|
|
120
179
|
.catch(() => { res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
|
|
@@ -125,6 +184,13 @@ export function createTollbooth(config = {}) {
|
|
|
125
184
|
|
|
126
185
|
tollbooth.shouldCharge = shouldCharge;
|
|
127
186
|
tollbooth.pow = powEngine;
|
|
187
|
+
tollbooth.observe = observe;
|
|
188
|
+
// Live counters for operators: how much traffic, how much was charged, and how
|
|
189
|
+
// it was settled. A point-in-time snapshot (never mutated by the caller).
|
|
190
|
+
// .stats() is sync (in-process mirror). .snapshot() is async (durable sink).
|
|
191
|
+
tollbooth.stats = () => ({ ...mem.snapshot(), difficultyNow: difficultyNow(), observe });
|
|
192
|
+
tollbooth.snapshot = async () => ({ ...(await sink.snapshot()), difficultyNow: difficultyNow(), observe });
|
|
193
|
+
tollbooth.flush = () => Promise.resolve(sink.flush && sink.flush());
|
|
128
194
|
return tollbooth;
|
|
129
195
|
}
|
|
130
196
|
|
|
@@ -187,6 +253,11 @@ async function startCli() {
|
|
|
187
253
|
const { default: express } = await import("express");
|
|
188
254
|
const app = express();
|
|
189
255
|
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
|
256
|
+
// Operator analytics — aggregate counts only (no per-request data), mounted
|
|
257
|
+
// before the gate so they're always reachable and never themselves charged.
|
|
258
|
+
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()));
|
|
190
261
|
app.use(gate);
|
|
191
262
|
if (upstream) {
|
|
192
263
|
app.use(createProxy(upstream));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
"agent402-tollbooth": "index.js"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./index.js"
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./edge": "./edge.js",
|
|
13
|
+
"./edge.js": "./edge.js",
|
|
14
|
+
"./worker": "./worker.js",
|
|
15
|
+
"./worker.js": "./worker.js",
|
|
16
|
+
"./dashboard.js": "./dashboard.js",
|
|
17
|
+
"./sinks.js": "./sinks.js"
|
|
12
18
|
},
|
|
13
19
|
"files": [
|
|
14
20
|
"index.js",
|
|
@@ -16,6 +22,8 @@
|
|
|
16
22
|
"worker.js",
|
|
17
23
|
"bots.js",
|
|
18
24
|
"pow.js",
|
|
25
|
+
"dashboard.js",
|
|
26
|
+
"sinks.js",
|
|
19
27
|
"example.js",
|
|
20
28
|
"demo.js",
|
|
21
29
|
"README.md"
|
|
@@ -24,7 +32,7 @@
|
|
|
24
32
|
"node": ">=20"
|
|
25
33
|
},
|
|
26
34
|
"scripts": {
|
|
27
|
-
"test": "node test.js && node edge.test.js",
|
|
35
|
+
"test": "node test.js && node edge.test.js && node features.test.js",
|
|
28
36
|
"demo": "node demo.js",
|
|
29
37
|
"example": "node example.js"
|
|
30
38
|
},
|
package/pow.js
CHANGED
|
@@ -35,19 +35,22 @@ export function createPow({
|
|
|
35
35
|
|
|
36
36
|
const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
37
37
|
|
|
38
|
-
/** Mint a signed, resource-scoped challenge.
|
|
39
|
-
|
|
38
|
+
/** Mint a signed, resource-scoped challenge. `diff` lets the caller raise the
|
|
39
|
+
* difficulty for this one challenge (e.g. adaptive-under-load); verify reads
|
|
40
|
+
* the difficulty from the token, so any value is self-describing and safe. */
|
|
41
|
+
function challenge(resource, diff = difficulty) {
|
|
42
|
+
const d = Math.max(1, Math.min(Math.floor(Number(diff) || difficulty), 32));
|
|
40
43
|
const chal = randomBytes(16).toString("hex");
|
|
41
44
|
const exp = Date.now() + ttlMs;
|
|
42
|
-
const payload = `${chal}.${exp}.${
|
|
45
|
+
const payload = `${chal}.${exp}.${d}.${resource}`;
|
|
43
46
|
const token = `${payload}.${sign(payload)}`;
|
|
44
47
|
return {
|
|
45
48
|
algorithm: "sha256",
|
|
46
49
|
challenge: chal,
|
|
47
|
-
difficulty,
|
|
50
|
+
difficulty: d,
|
|
48
51
|
expires: exp,
|
|
49
52
|
token,
|
|
50
|
-
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${
|
|
53
|
+
rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${d} leading zero bits, then resend the request with header X-Pow-Solution: ${token}:<nonce>`,
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
|
package/sinks.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Pluggable stats sinks for the tollbooth — turn the in-memory counter into
|
|
2
|
+
// something that survives restart and aggregates across processes / isolates.
|
|
3
|
+
//
|
|
4
|
+
// A sink is just:
|
|
5
|
+
// {
|
|
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
|
|
8
|
+
// snapshot() // returns the aggregated counts as { field: number, ... }
|
|
9
|
+
// }
|
|
10
|
+
//
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// Want strong consistency on Cloudflare? Back it with a Durable Object that
|
|
17
|
+
// implements the same { incr, snapshot } interface and pass it as `statsSink`.
|
|
18
|
+
|
|
19
|
+
const ZERO = () => ({
|
|
20
|
+
requests: 0,
|
|
21
|
+
freeAllowed: 0,
|
|
22
|
+
wouldCharge: 0,
|
|
23
|
+
charged: 0,
|
|
24
|
+
powSolved: 0,
|
|
25
|
+
x402Paid: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** In-process counter. Synchronous; loses state on restart. */
|
|
29
|
+
export function memorySink() {
|
|
30
|
+
const c = { since: new Date().toISOString(), ...ZERO() };
|
|
31
|
+
return {
|
|
32
|
+
incr(field, n = 1) { c[field] = (c[field] || 0) + n; },
|
|
33
|
+
flush() { return Promise.resolve(); },
|
|
34
|
+
snapshot() { return { ...c }; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cloudflare KV-backed sink. Writes are append-only delta records under
|
|
40
|
+
* `tb:stats:${bucket}:*`; snapshot lists + sums them. Bounded by `ttlSeconds`.
|
|
41
|
+
*
|
|
42
|
+
* Pattern notes:
|
|
43
|
+
* - Cloudflare Workers don't run timers between requests. The gate calls
|
|
44
|
+
* `incr()` synchronously into an in-isolate buffer; the Worker entry must
|
|
45
|
+
* call `flush()` (typically inside `ctx.waitUntil(...)`) so the buffered
|
|
46
|
+
* delta becomes a KV write. Without that, deltas stay in isolate memory
|
|
47
|
+
* and die with the isolate.
|
|
48
|
+
* - KV has no atomic increment, so we never RMW a shared counter. Each
|
|
49
|
+
* flush writes a brand-new key (unique id + ts) — concurrent isolates
|
|
50
|
+
* can't lose each other's deltas. Snapshot sums every key under the prefix.
|
|
51
|
+
* - Tradeoff: snapshot reads list+get every key, which is fine for low traffic
|
|
52
|
+
* (deltas batched per request) but expensive for very high traffic. For
|
|
53
|
+
* that, back with a Durable Object instead.
|
|
54
|
+
*/
|
|
55
|
+
export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24 * 30 } = {}) {
|
|
56
|
+
const prefix = `tb:stats:${bucket}:`;
|
|
57
|
+
const buf = {};
|
|
58
|
+
const isDirty = () => Object.keys(buf).length > 0;
|
|
59
|
+
return {
|
|
60
|
+
incr(field, n = 1) { buf[field] = (buf[field] || 0) + n; },
|
|
61
|
+
flush() {
|
|
62
|
+
if (!isDirty()) return Promise.resolve();
|
|
63
|
+
const payload = { ...buf, _ts: Date.now() };
|
|
64
|
+
for (const k of Object.keys(buf)) delete buf[k];
|
|
65
|
+
const key = prefix + `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
66
|
+
return Promise.resolve(kv.put(key, JSON.stringify(payload), { expirationTtl: ttlSeconds })).catch(() => {});
|
|
67
|
+
},
|
|
68
|
+
async snapshot() {
|
|
69
|
+
const result = ZERO();
|
|
70
|
+
let oldest = Infinity;
|
|
71
|
+
let cursor;
|
|
72
|
+
do {
|
|
73
|
+
const page = await kv.list({ prefix, cursor });
|
|
74
|
+
for (const { name } of page.keys) {
|
|
75
|
+
let raw;
|
|
76
|
+
try { raw = await kv.get(name); } catch { continue; }
|
|
77
|
+
if (!raw) continue;
|
|
78
|
+
let obj; try { obj = JSON.parse(raw); } catch { continue; }
|
|
79
|
+
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;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
cursor = page.cursor;
|
|
86
|
+
if (page.list_complete || !cursor) break;
|
|
87
|
+
} while (cursor);
|
|
88
|
+
if (oldest !== Infinity) result.since = new Date(oldest).toISOString();
|
|
89
|
+
return result;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* HTTP-backed sink. Batched POSTs of `{ incr: { field: n, ... }, ts }`.
|
|
96
|
+
* GET on the same URL returns the aggregated snapshot — wire that up on your
|
|
97
|
+
* collector (a tiny Vercel route hander, an Express app, anything).
|
|
98
|
+
*
|
|
99
|
+
* incr(): buffers locally and schedules a flush via setTimeout (Node) or
|
|
100
|
+
* returns immediately on the edge (caller should invoke flush()
|
|
101
|
+
* inside ctx.waitUntil so the POST survives the response).
|
|
102
|
+
* flush(): force-send the current buffer right now (used by edge entries).
|
|
103
|
+
* snapshot(): GET the collector URL.
|
|
104
|
+
*
|
|
105
|
+
* Auth: pass `token` to include `Authorization: Bearer <token>`.
|
|
106
|
+
*/
|
|
107
|
+
export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
|
|
108
|
+
const f = fetchImpl || globalThis.fetch;
|
|
109
|
+
if (typeof f !== "function") throw new Error("httpStatsSink: no fetch available (pass fetchImpl or run on Node 18+)");
|
|
110
|
+
const buf = {};
|
|
111
|
+
let timer = null;
|
|
112
|
+
const headers = { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
|
|
113
|
+
async function postBuf() {
|
|
114
|
+
timer = null;
|
|
115
|
+
if (Object.keys(buf).length === 0) return;
|
|
116
|
+
const payload = { ...buf };
|
|
117
|
+
for (const k of Object.keys(buf)) delete buf[k];
|
|
118
|
+
try {
|
|
119
|
+
await f(url, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers,
|
|
122
|
+
body: JSON.stringify({ incr: payload, ts: Date.now() }),
|
|
123
|
+
signal: AbortSignal.timeout(5000),
|
|
124
|
+
});
|
|
125
|
+
} catch { /* fire-and-forget: stats loss is acceptable here */ }
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
incr(field, n = 1) {
|
|
129
|
+
buf[field] = (buf[field] || 0) + n;
|
|
130
|
+
// Batched flush. setTimeout works on Node; on the edge the caller should
|
|
131
|
+
// still invoke flush() inside ctx.waitUntil because timers don't fire
|
|
132
|
+
// after the response is returned.
|
|
133
|
+
if (!timer && typeof setTimeout === "function") timer = setTimeout(postBuf, batchMs);
|
|
134
|
+
},
|
|
135
|
+
flush: postBuf,
|
|
136
|
+
async snapshot() {
|
|
137
|
+
try {
|
|
138
|
+
const r = await f(url, {
|
|
139
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
140
|
+
signal: AbortSignal.timeout(5000),
|
|
141
|
+
});
|
|
142
|
+
if (!r.ok) return ZERO();
|
|
143
|
+
const j = await r.json();
|
|
144
|
+
return { ...ZERO(), ...j };
|
|
145
|
+
} catch { return ZERO(); }
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
package/worker.js
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
// [vars]
|
|
9
9
|
// TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
|
|
10
10
|
// TOLLBOOTH_PAYTO = "0xYourWallet" # optional (advertises USDC quote)
|
|
11
|
+
// TOLLBOOTH_OBSERVE = "true" # optional: observe-only, never 402
|
|
12
|
+
// TOLLBOOTH_STATS_TOKEN = "any long string" # optional: gate /__tollbooth/stats
|
|
11
13
|
// # secret (required): wrangler secret put TOLLBOOTH_SECRET
|
|
12
14
|
// # optional single-use store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
|
|
13
|
-
|
|
15
|
+
// # ↑ same TOLLBOOTH_KV is reused for durable stats aggregation across isolates.
|
|
16
|
+
import { createEdgeTollbooth, kvStatsSink } from "./edge.js";
|
|
17
|
+
import { dashboardHtml } from "./dashboard.js";
|
|
14
18
|
|
|
15
19
|
const kvStore = (kv) => ({
|
|
16
20
|
// Best-effort atomic claim. KV has no native compare-and-set, so this is
|
|
@@ -24,7 +28,7 @@ const kvStore = (kv) => ({
|
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
export default {
|
|
27
|
-
async fetch(request, env) {
|
|
31
|
+
async fetch(request, env, ctx) {
|
|
28
32
|
if (!env.TOLLBOOTH_SECRET) {
|
|
29
33
|
return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
|
|
30
34
|
}
|
|
@@ -33,6 +37,11 @@ export default {
|
|
|
33
37
|
// can be reused across isolates within its TTL. Bind a KV namespace for prod.
|
|
34
38
|
console.warn("agent402-tollbooth: no TOLLBOOTH_KV bound — proof-of-work replay protection is per-isolate only. Bind a KV namespace for production.");
|
|
35
39
|
}
|
|
40
|
+
// Durable stats live in KV if a namespace is bound. Without it, the dashboard
|
|
41
|
+
// is per-isolate (dies on cold start) — fine for dev, useless for prod.
|
|
42
|
+
const statsSink = env.TOLLBOOTH_KV
|
|
43
|
+
? kvStatsSink(env.TOLLBOOTH_KV, { bucket: env.TOLLBOOTH_STATS_BUCKET || "default" })
|
|
44
|
+
: undefined;
|
|
36
45
|
const gate = createEdgeTollbooth({
|
|
37
46
|
secret: env.TOLLBOOTH_SECRET,
|
|
38
47
|
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
@@ -40,9 +49,30 @@ export default {
|
|
|
40
49
|
network: env.TOLLBOOTH_NETWORK || "base",
|
|
41
50
|
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
42
51
|
store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
|
|
52
|
+
observe: env.TOLLBOOTH_OBSERVE === "true",
|
|
53
|
+
statsSink,
|
|
43
54
|
});
|
|
44
55
|
|
|
56
|
+
// Free, never-gated operator endpoints. Mounted BEFORE the gate so they
|
|
57
|
+
// can't be paywalled — and so the dashboard polls work even when the rest
|
|
58
|
+
// of the origin is fully gated.
|
|
59
|
+
const u = new URL(request.url);
|
|
60
|
+
if (u.pathname === "/__tollbooth" || u.pathname === "/__tollbooth/") {
|
|
61
|
+
return new Response(dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
62
|
+
}
|
|
63
|
+
if (u.pathname === "/__tollbooth/stats") {
|
|
64
|
+
// Optional bearer-token gate — share with your monitoring caller.
|
|
65
|
+
if (env.TOLLBOOTH_STATS_TOKEN) {
|
|
66
|
+
const got = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
|
67
|
+
if (got !== env.TOLLBOOTH_STATS_TOKEN) return new Response("unauthorized", { status: 401 });
|
|
68
|
+
}
|
|
69
|
+
const snap = await gate.snapshot();
|
|
70
|
+
return new Response(JSON.stringify(snap), { headers: { "content-type": "application/json" } });
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
const blocked = await gate(request);
|
|
74
|
+
// Flush any buffered stats to KV after we've replied — survives the response.
|
|
75
|
+
if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(gate.flush());
|
|
46
76
|
if (blocked) return blocked;
|
|
47
77
|
|
|
48
78
|
// Allowed → proxy to the origin.
|