agent402-tollbooth 0.2.0 → 0.3.1
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 +84 -3
- package/dashboard.js +24 -6
- package/edge.js +37 -4
- package/index.js +39 -7
- package/package.json +9 -2
- package/sinks.js +162 -0
- package/worker.js +46 -2
package/README.md
CHANGED
|
@@ -175,10 +175,13 @@ app.use(createTollbooth({
|
|
|
175
175
|
| `free(req)` | – | Custom force-allow predicate (wins over everything) |
|
|
176
176
|
| `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
|
|
177
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)`. |
|
|
178
180
|
|
|
179
181
|
Environment variables: `TOLLBOOTH_UPSTREAM`, `TOLLBOOTH_PAYTO`, `TOLLBOOTH_PRICE`,
|
|
180
182
|
`TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_MODE`, `TOLLBOOTH_ADAPTIVE`,
|
|
181
|
-
`TOLLBOOTH_MAX_POW_BITS`, `TOLLBOOTH_ADAPTIVE_PER_BIT`, `TOLLBOOTH_SECRET`,
|
|
183
|
+
`TOLLBOOTH_MAX_POW_BITS`, `TOLLBOOTH_ADAPTIVE_PER_BIT`, `TOLLBOOTH_SECRET`,
|
|
184
|
+
`TOLLBOOTH_OBSERVE`, `TOLLBOOTH_STATS_TOKEN`, `TOLLBOOTH_STATS_BUCKET`, `PORT`.
|
|
182
185
|
|
|
183
186
|
## How it decides who pays
|
|
184
187
|
|
|
@@ -199,14 +202,92 @@ something*:
|
|
|
199
202
|
scraper pays escalating CPU per request regardless of how it looks — detection is
|
|
200
203
|
cat-and-mouse, economics isn't.
|
|
201
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
|
+
|
|
202
223
|
## Analytics
|
|
203
224
|
|
|
204
|
-
The middleware keeps aggregate counters (no per-request data):
|
|
205
|
-
|
|
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
|
+
|
|
206
230
|
The reverse-proxy CLI exposes them as JSON at **`/__tollbooth/stats`** and as a
|
|
207
231
|
live **dashboard at `/__tollbooth`** — requests, how many were charged,
|
|
208
232
|
proof-of-work solves, USDC collected, and what share of your traffic is bots.
|
|
209
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.
|
|
290
|
+
|
|
210
291
|
## Production checklist (read this)
|
|
211
292
|
|
|
212
293
|
- **Set a stable `TOLLBOOTH_SECRET`.** Required for any multi-process/clustered
|
package/dashboard.js
CHANGED
|
@@ -25,20 +25,38 @@ export function dashboardHtml() {
|
|
|
25
25
|
a{color:var(--accent)}
|
|
26
26
|
</style></head>
|
|
27
27
|
<body><div class="wrap">
|
|
28
|
-
<h1><span class="badge">402</span> agent402-tollbooth
|
|
28
|
+
<h1><span class="badge">402</span> agent402-tollbooth <span id="mode"></span></h1>
|
|
29
29
|
<div class="sub">Live pay-per-crawl stats · refreshes every 5s · <span id="since"></span></div>
|
|
30
30
|
<div class="grid" id="grid"></div>
|
|
31
|
-
<footer
|
|
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
32
|
</div>
|
|
33
33
|
<script>
|
|
34
|
-
const cards=[
|
|
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
|
+
];
|
|
35
43
|
async function tick(){
|
|
36
44
|
try{
|
|
37
45
|
const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
// Counters are always numbers — coerce, never trust raw JSON from the
|
|
47
|
+
// configured stats endpoint (a compromised collector with httpStatsSink
|
|
48
|
+
// could otherwise inject HTML through this innerHTML sink).
|
|
49
|
+
document.getElementById("grid").innerHTML=cards.map(function(c){
|
|
50
|
+
var k=c[0],label=c[1],acc=c[2];
|
|
51
|
+
if(k==="wouldCharge" && !(Number(s.wouldCharge)>0) && !s.observe) return "";
|
|
52
|
+
var v=Number(s[k]); if(!Number.isFinite(v)) v=0;
|
|
53
|
+
var lbl=String(label).replace(/[&<>"']/g,function(ch){return({'&':'&','<':'<','>':'>','"':'"',"'":'''})[ch];});
|
|
54
|
+
return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
|
|
55
|
+
}).join("");
|
|
56
|
+
var botish=(s.charged||0)+(s.wouldCharge||0);
|
|
57
|
+
var pct=s.requests?Math.round((botish/s.requests)*100):0;
|
|
41
58
|
document.getElementById("botpct").textContent=pct+"%";
|
|
59
|
+
document.getElementById("mode").textContent=s.observe?" · OBSERVE":"";
|
|
42
60
|
if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
|
|
43
61
|
}catch(e){/* keep last values */}
|
|
44
62
|
}
|
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
|
|
|
@@ -128,11 +131,27 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
128
131
|
charge,
|
|
129
132
|
free,
|
|
130
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,
|
|
131
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.",
|
|
132
142
|
} = config;
|
|
133
143
|
|
|
134
144
|
const isBot = makeBotMatcher(botUserAgents);
|
|
135
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
|
+
// Stats must never break a request path — a buggy custom sink can throw
|
|
150
|
+
// synchronously; we shrug and continue.
|
|
151
|
+
const incr = (k, n = 1) => {
|
|
152
|
+
try { mem.incr(k, n); } catch { /* ignore */ }
|
|
153
|
+
if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
|
|
154
|
+
};
|
|
136
155
|
|
|
137
156
|
const looksHuman = (request) => {
|
|
138
157
|
const ua = request.headers.get("user-agent") || "";
|
|
@@ -149,17 +168,20 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
149
168
|
return isBot(request.headers.get("user-agent") || ""); // "bots" (default)
|
|
150
169
|
};
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
async function gate(request) {
|
|
172
|
+
incr("requests");
|
|
173
|
+
if (!shouldCharge(request)) { incr("freeAllowed"); return null; }
|
|
174
|
+
if (observe) { incr("wouldCharge"); return null; }
|
|
154
175
|
const u = new URL(request.url);
|
|
155
176
|
const resource = (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + u.pathname + u.search;
|
|
156
177
|
|
|
157
178
|
const sol = request.headers.get("x-pow-solution");
|
|
158
179
|
if (powEngine && sol) {
|
|
159
180
|
const r = await powEngine.verify(sol, resource);
|
|
160
|
-
if (r.ok) return null;
|
|
181
|
+
if (r.ok) { incr("powSolved"); return null; }
|
|
161
182
|
}
|
|
162
183
|
|
|
184
|
+
incr("charged");
|
|
163
185
|
const body = {
|
|
164
186
|
error: "Payment Required",
|
|
165
187
|
message,
|
|
@@ -170,5 +192,16 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
170
192
|
status: 402,
|
|
171
193
|
headers: { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" },
|
|
172
194
|
});
|
|
173
|
-
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Operator surface: in-process mirror (sync), durable snapshot (async),
|
|
198
|
+
// and an explicit flush() for edge runtimes — the Worker should call this
|
|
199
|
+
// inside ctx.waitUntil so buffered KV writes actually happen.
|
|
200
|
+
gate.observe = observe;
|
|
201
|
+
gate.stats = () => ({ ...mem.snapshot(), observe });
|
|
202
|
+
gate.snapshot = async () => ({ ...(await sink.snapshot()), observe });
|
|
203
|
+
// flush() is typically wired to ctx.waitUntil — swallow errors so a sink
|
|
204
|
+
// outage can't surface as an unhandled rejection after the response.
|
|
205
|
+
gate.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
|
|
206
|
+
return gate;
|
|
174
207
|
}
|
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 {
|
|
@@ -72,6 +76,13 @@ export function createTollbooth(config = {}) {
|
|
|
72
76
|
adaptive = config.adaptive ?? (process.env.TOLLBOOTH_ADAPTIVE === "true"),
|
|
73
77
|
adaptivePerBit = Number(process.env.TOLLBOOTH_ADAPTIVE_PER_BIT) || 300, // +1 bit per N charged req/min
|
|
74
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,
|
|
75
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.",
|
|
76
87
|
} = config;
|
|
77
88
|
|
|
@@ -79,7 +90,18 @@ export function createTollbooth(config = {}) {
|
|
|
79
90
|
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
80
91
|
|
|
81
92
|
// Passive analytics — never affects request handling, just counts what happens.
|
|
82
|
-
|
|
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
|
+
// Never let a buggy custom sink throw inside the request path — stats are
|
|
100
|
+
// non-critical and must not be able to break a payment decision.
|
|
101
|
+
const incr = (k, n = 1) => {
|
|
102
|
+
try { mem.incr(k, n); } catch { /* ignore */ }
|
|
103
|
+
if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
|
|
104
|
+
};
|
|
83
105
|
|
|
84
106
|
const looksHuman = (req) => {
|
|
85
107
|
const ua = req.headers["user-agent"] || "";
|
|
@@ -118,12 +140,15 @@ export function createTollbooth(config = {}) {
|
|
|
118
140
|
};
|
|
119
141
|
|
|
120
142
|
function tollbooth(req, res, next) {
|
|
121
|
-
|
|
122
|
-
if (!shouldCharge(req)) {
|
|
143
|
+
incr("requests");
|
|
144
|
+
if (!shouldCharge(req)) { incr("freeAllowed"); return next(); }
|
|
145
|
+
// Observe-only: classify as would-charge but let it through. Lets operators
|
|
146
|
+
// measure bot traffic on a live site before turning on enforcement.
|
|
147
|
+
if (observe) { incr("wouldCharge"); res.setHeader("X-Tollbooth-Observed", "would-charge"); return next(); }
|
|
123
148
|
const resource = resourceOf(req);
|
|
124
149
|
|
|
125
150
|
const send402 = (extra = {}) => {
|
|
126
|
-
|
|
151
|
+
incr("charged");
|
|
127
152
|
if (adaptive) chargedWindow.push(Date.now());
|
|
128
153
|
const body = {
|
|
129
154
|
error: "Payment Required",
|
|
@@ -141,7 +166,7 @@ export function createTollbooth(config = {}) {
|
|
|
141
166
|
const powHeader = req.headers["x-pow-solution"];
|
|
142
167
|
if (powEngine && powHeader) {
|
|
143
168
|
const r = powEngine.verify(powHeader, resource);
|
|
144
|
-
if (r.ok) {
|
|
169
|
+
if (r.ok) { incr("powSolved"); res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
145
170
|
res.setHeader("X-Pow-Error", r.reason);
|
|
146
171
|
}
|
|
147
172
|
|
|
@@ -153,7 +178,7 @@ export function createTollbooth(config = {}) {
|
|
|
153
178
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
|
|
154
179
|
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
|
|
155
180
|
.then((ok) => {
|
|
156
|
-
if (ok) {
|
|
181
|
+
if (ok) { incr("x402Paid"); res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
157
182
|
send402();
|
|
158
183
|
})
|
|
159
184
|
.catch(() => { res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
|
|
@@ -164,9 +189,16 @@ export function createTollbooth(config = {}) {
|
|
|
164
189
|
|
|
165
190
|
tollbooth.shouldCharge = shouldCharge;
|
|
166
191
|
tollbooth.pow = powEngine;
|
|
192
|
+
tollbooth.observe = observe;
|
|
167
193
|
// Live counters for operators: how much traffic, how much was charged, and how
|
|
168
194
|
// it was settled. A point-in-time snapshot (never mutated by the caller).
|
|
169
|
-
|
|
195
|
+
// .stats() is sync (in-process mirror). .snapshot() is async (durable sink).
|
|
196
|
+
tollbooth.stats = () => ({ ...mem.snapshot(), difficultyNow: difficultyNow(), observe });
|
|
197
|
+
tollbooth.snapshot = async () => ({ ...(await sink.snapshot()), difficultyNow: difficultyNow(), observe });
|
|
198
|
+
// Swallow flush errors — flush() is typically wired to ctx.waitUntil on the
|
|
199
|
+
// edge; an unhandled rejection there pollutes logs without affecting the
|
|
200
|
+
// already-sent response.
|
|
201
|
+
tollbooth.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
|
|
170
202
|
return tollbooth;
|
|
171
203
|
}
|
|
172
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -17,6 +23,7 @@
|
|
|
17
23
|
"bots.js",
|
|
18
24
|
"pow.js",
|
|
19
25
|
"dashboard.js",
|
|
26
|
+
"sinks.js",
|
|
20
27
|
"example.js",
|
|
21
28
|
"demo.js",
|
|
22
29
|
"README.md"
|
package/sinks.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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 KNOWN_FIELDS = ["requests", "freeAllowed", "wouldCharge", "charged", "powSolved", "x402Paid"];
|
|
20
|
+
const ZERO = () => Object.fromEntries(KNOWN_FIELDS.map((k) => [k, 0]));
|
|
21
|
+
|
|
22
|
+
// Filter an untrusted JSON blob (KV value or HTTP collector response) to only
|
|
23
|
+
// the known numeric counters. Prevents a compromised store from injecting
|
|
24
|
+
// arbitrary keys or non-numeric values that downstream renderers (e.g.
|
|
25
|
+
// dashboard.js) might treat as HTML. Defense in depth — the dashboard also
|
|
26
|
+
// coerces, but trim at the boundary.
|
|
27
|
+
const sanitize = (obj) => {
|
|
28
|
+
const out = ZERO();
|
|
29
|
+
if (!obj || typeof obj !== "object") return out;
|
|
30
|
+
for (const k of KNOWN_FIELDS) {
|
|
31
|
+
const v = Number(obj[k]);
|
|
32
|
+
if (Number.isFinite(v) && v >= 0) out[k] = v;
|
|
33
|
+
}
|
|
34
|
+
if (typeof obj.since === "string" && obj.since.length < 64) out.since = obj.since;
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** In-process counter. Synchronous; loses state on restart. */
|
|
39
|
+
export function memorySink() {
|
|
40
|
+
const c = { since: new Date().toISOString(), ...ZERO() };
|
|
41
|
+
return {
|
|
42
|
+
incr(field, n = 1) { c[field] = (c[field] || 0) + n; },
|
|
43
|
+
flush() { return Promise.resolve(); },
|
|
44
|
+
snapshot() { return { ...c }; },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cloudflare KV-backed sink. Writes are append-only delta records under
|
|
50
|
+
* `tb:stats:${bucket}:*`; snapshot lists + sums them. Bounded by `ttlSeconds`.
|
|
51
|
+
*
|
|
52
|
+
* Pattern notes:
|
|
53
|
+
* - Cloudflare Workers don't run timers between requests. The gate calls
|
|
54
|
+
* `incr()` synchronously into an in-isolate buffer; the Worker entry must
|
|
55
|
+
* call `flush()` (typically inside `ctx.waitUntil(...)`) so the buffered
|
|
56
|
+
* delta becomes a KV write. Without that, deltas stay in isolate memory
|
|
57
|
+
* and die with the isolate.
|
|
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
|
|
60
|
+
* can't lose each other's deltas. Snapshot sums every key under the prefix.
|
|
61
|
+
* - Tradeoff: snapshot reads list+get every key, which is fine for low traffic
|
|
62
|
+
* (deltas batched per request) but expensive for very high traffic. For
|
|
63
|
+
* that, back with a Durable Object instead.
|
|
64
|
+
*/
|
|
65
|
+
export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24 * 30 } = {}) {
|
|
66
|
+
const prefix = `tb:stats:${bucket}:`;
|
|
67
|
+
const buf = {};
|
|
68
|
+
const isDirty = () => Object.keys(buf).length > 0;
|
|
69
|
+
return {
|
|
70
|
+
incr(field, n = 1) { buf[field] = (buf[field] || 0) + n; },
|
|
71
|
+
flush() {
|
|
72
|
+
if (!isDirty()) return Promise.resolve();
|
|
73
|
+
const payload = { ...buf, _ts: Date.now() };
|
|
74
|
+
for (const k of Object.keys(buf)) delete buf[k];
|
|
75
|
+
const key = prefix + `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
76
|
+
return Promise.resolve(kv.put(key, JSON.stringify(payload), { expirationTtl: ttlSeconds })).catch(() => {});
|
|
77
|
+
},
|
|
78
|
+
async snapshot() {
|
|
79
|
+
const result = ZERO();
|
|
80
|
+
let oldest = Infinity;
|
|
81
|
+
let cursor;
|
|
82
|
+
do {
|
|
83
|
+
const page = await kv.list({ prefix, cursor });
|
|
84
|
+
for (const { name } of page.keys) {
|
|
85
|
+
let raw;
|
|
86
|
+
try { raw = await kv.get(name); } catch { continue; }
|
|
87
|
+
if (!raw) continue;
|
|
88
|
+
let obj; try { obj = JSON.parse(raw); } catch { continue; }
|
|
89
|
+
if (typeof obj._ts === "number" && obj._ts < oldest) oldest = obj._ts;
|
|
90
|
+
// Whitelist + numeric coerce: a misconfigured (or compromised) KV
|
|
91
|
+
// entry can't smuggle arbitrary keys or strings into the snapshot.
|
|
92
|
+
for (const k of KNOWN_FIELDS) {
|
|
93
|
+
const v = Number(obj[k]);
|
|
94
|
+
if (Number.isFinite(v) && v >= 0) result[k] = (result[k] || 0) + v;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
cursor = page.cursor;
|
|
98
|
+
if (page.list_complete || !cursor) break;
|
|
99
|
+
} while (cursor);
|
|
100
|
+
if (oldest !== Infinity) result.since = new Date(oldest).toISOString();
|
|
101
|
+
return result;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
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
|
|
109
|
+
* collector (a tiny Vercel route hander, an Express app, anything).
|
|
110
|
+
*
|
|
111
|
+
* incr(): buffers locally and schedules a flush via setTimeout (Node) or
|
|
112
|
+
* returns immediately on the edge (caller should invoke flush()
|
|
113
|
+
* inside ctx.waitUntil so the POST survives the response).
|
|
114
|
+
* flush(): force-send the current buffer right now (used by edge entries).
|
|
115
|
+
* snapshot(): GET the collector URL.
|
|
116
|
+
*
|
|
117
|
+
* Auth: pass `token` to include `Authorization: Bearer <token>`.
|
|
118
|
+
*/
|
|
119
|
+
export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
|
|
120
|
+
const f = fetchImpl || globalThis.fetch;
|
|
121
|
+
if (typeof f !== "function") throw new Error("httpStatsSink: no fetch available (pass fetchImpl or run on Node 18+)");
|
|
122
|
+
const buf = {};
|
|
123
|
+
let timer = null;
|
|
124
|
+
const headers = { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
|
|
125
|
+
async function postBuf() {
|
|
126
|
+
timer = null;
|
|
127
|
+
if (Object.keys(buf).length === 0) return;
|
|
128
|
+
const payload = { ...buf };
|
|
129
|
+
for (const k of Object.keys(buf)) delete buf[k];
|
|
130
|
+
try {
|
|
131
|
+
await f(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers,
|
|
134
|
+
body: JSON.stringify({ incr: payload, ts: Date.now() }),
|
|
135
|
+
signal: AbortSignal.timeout(5000),
|
|
136
|
+
});
|
|
137
|
+
} catch { /* fire-and-forget: stats loss is acceptable here */ }
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
incr(field, n = 1) {
|
|
141
|
+
buf[field] = (buf[field] || 0) + n;
|
|
142
|
+
// Batched flush. setTimeout works on Node; on the edge the caller should
|
|
143
|
+
// still invoke flush() inside ctx.waitUntil because timers don't fire
|
|
144
|
+
// after the response is returned.
|
|
145
|
+
if (!timer && typeof setTimeout === "function") timer = setTimeout(postBuf, batchMs);
|
|
146
|
+
},
|
|
147
|
+
flush: postBuf,
|
|
148
|
+
async snapshot() {
|
|
149
|
+
try {
|
|
150
|
+
const r = await f(url, {
|
|
151
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
152
|
+
signal: AbortSignal.timeout(5000),
|
|
153
|
+
});
|
|
154
|
+
if (!r.ok) return ZERO();
|
|
155
|
+
// Sanitize at the trust boundary — a compromised or misconfigured
|
|
156
|
+
// collector cannot inject non-numeric values or extra keys that the
|
|
157
|
+
// dashboard would render as HTML.
|
|
158
|
+
return sanitize(await r.json());
|
|
159
|
+
} catch { return ZERO(); }
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
package/worker.js
CHANGED
|
@@ -8,9 +8,24 @@
|
|
|
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";
|
|
18
|
+
|
|
19
|
+
// Constant-time string compare — Cloudflare Workers don't ship node:crypto's
|
|
20
|
+
// timingSafeEqual. Short-circuiting `===` on a secret token leaks length and
|
|
21
|
+
// prefix bits to a sufficiently patient attacker; this doesn't.
|
|
22
|
+
function constEq(a, b) {
|
|
23
|
+
a = String(a || ""); b = String(b || "");
|
|
24
|
+
if (a.length !== b.length) return false;
|
|
25
|
+
let r = 0;
|
|
26
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
27
|
+
return r === 0;
|
|
28
|
+
}
|
|
14
29
|
|
|
15
30
|
const kvStore = (kv) => ({
|
|
16
31
|
// Best-effort atomic claim. KV has no native compare-and-set, so this is
|
|
@@ -24,7 +39,7 @@ const kvStore = (kv) => ({
|
|
|
24
39
|
});
|
|
25
40
|
|
|
26
41
|
export default {
|
|
27
|
-
async fetch(request, env) {
|
|
42
|
+
async fetch(request, env, ctx) {
|
|
28
43
|
if (!env.TOLLBOOTH_SECRET) {
|
|
29
44
|
return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
|
|
30
45
|
}
|
|
@@ -33,6 +48,11 @@ export default {
|
|
|
33
48
|
// can be reused across isolates within its TTL. Bind a KV namespace for prod.
|
|
34
49
|
console.warn("agent402-tollbooth: no TOLLBOOTH_KV bound — proof-of-work replay protection is per-isolate only. Bind a KV namespace for production.");
|
|
35
50
|
}
|
|
51
|
+
// Durable stats live in KV if a namespace is bound. Without it, the dashboard
|
|
52
|
+
// is per-isolate (dies on cold start) — fine for dev, useless for prod.
|
|
53
|
+
const statsSink = env.TOLLBOOTH_KV
|
|
54
|
+
? kvStatsSink(env.TOLLBOOTH_KV, { bucket: env.TOLLBOOTH_STATS_BUCKET || "default" })
|
|
55
|
+
: undefined;
|
|
36
56
|
const gate = createEdgeTollbooth({
|
|
37
57
|
secret: env.TOLLBOOTH_SECRET,
|
|
38
58
|
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
@@ -40,9 +60,33 @@ export default {
|
|
|
40
60
|
network: env.TOLLBOOTH_NETWORK || "base",
|
|
41
61
|
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
42
62
|
store: env.TOLLBOOTH_KV ? kvStore(env.TOLLBOOTH_KV) : undefined,
|
|
63
|
+
observe: env.TOLLBOOTH_OBSERVE === "true",
|
|
64
|
+
statsSink,
|
|
43
65
|
});
|
|
44
66
|
|
|
67
|
+
// Free, never-gated operator endpoints. Mounted BEFORE the gate so they
|
|
68
|
+
// can't be paywalled — and so the dashboard polls work even when the rest
|
|
69
|
+
// of the origin is fully gated.
|
|
70
|
+
const u = new URL(request.url);
|
|
71
|
+
if (u.pathname === "/__tollbooth" || u.pathname === "/__tollbooth/") {
|
|
72
|
+
return new Response(dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
73
|
+
}
|
|
74
|
+
if (u.pathname === "/__tollbooth/stats") {
|
|
75
|
+
// Optional bearer-token gate — share with your monitoring caller. We
|
|
76
|
+
// recommend setting this in any prod deploy: without it, anyone on the
|
|
77
|
+
// internet can read aggregate counts (no per-request data, but still
|
|
78
|
+
// potentially sensitive competitive info).
|
|
79
|
+
if (env.TOLLBOOTH_STATS_TOKEN) {
|
|
80
|
+
const got = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
|
81
|
+
if (!constEq(got, env.TOLLBOOTH_STATS_TOKEN)) return new Response("unauthorized", { status: 401 });
|
|
82
|
+
}
|
|
83
|
+
const snap = await gate.snapshot();
|
|
84
|
+
return new Response(JSON.stringify(snap), { headers: { "content-type": "application/json" } });
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
const blocked = await gate(request);
|
|
88
|
+
// Flush any buffered stats to KV after we've replied — survives the response.
|
|
89
|
+
if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(gate.flush());
|
|
46
90
|
if (blocked) return blocked;
|
|
47
91
|
|
|
48
92
|
// Allowed → proxy to the origin.
|