agent402-tollbooth 0.4.3 → 0.6.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 +170 -42
- package/edge.js +19 -4
- package/index.js +175 -23
- package/package.json +6 -2
- package/pow.js +91 -9
- package/replay.js +118 -0
- package/worker.js +61 -8
package/README.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# agent402-tollbooth
|
|
2
2
|
|
|
3
3
|
**Open-source, self-hostable x402 "pay-per-crawl". Put it in front of any site or
|
|
4
|
-
API: humans browse free, AI crawlers and agents pay per request**
|
|
4
|
+
API: humans browse free, AI crawlers and agents pay per request** - either in
|
|
5
5
|
USDC over the [x402 protocol](https://x402.org), or for free by solving a
|
|
6
6
|
proof-of-work. No Cloudflare, no Stripe, no Merchant-of-Record, no signup.
|
|
7
7
|
|
|
8
8
|
The big platforms are converging on the same model: Cloudflare's
|
|
9
9
|
[pay-per-crawl](https://stackoverflow.blog/2026/02/26/how-pay-per-crawl-is-reshaping-data-monetization/)
|
|
10
10
|
and now its [Monetization Gateway](https://blog.cloudflare.com/monetization-gateway/)
|
|
11
|
-
(launched July 2026
|
|
11
|
+
(launched July 2026 - x402 charging in USDC on Base for anything behind Cloudflare:
|
|
12
12
|
pages, APIs, datasets, MCP tools) confirm that pay-per-request is the business model
|
|
13
13
|
of the agentic web. Tollbooth is the **open-source monetization gateway**: the same
|
|
14
14
|
idea, but MIT-licensed and live today, running in
|
|
15
15
|
front of *any* origin (even a Cloudflare Worker), non-custodial (you hold the wallet,
|
|
16
|
-
no signup or Merchant-of-Record), and
|
|
16
|
+
no signup or Merchant-of-Record), and - the wedge the platform gateways don't offer -
|
|
17
17
|
with a **proof-of-work free tier** so a walletless agent still has a path through.
|
|
18
18
|
Built on the same hardened 402 + proof-of-work machinery as
|
|
19
19
|
[Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
@@ -25,10 +25,10 @@ npx agent402-tollbooth # then, in the repo: npm run --prefix tollbooth demo
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
```text
|
|
28
|
-
agent402-tollbooth
|
|
28
|
+
agent402-tollbooth - live pay-per-crawl demo
|
|
29
29
|
|
|
30
30
|
① A human opens the page (normal browser)
|
|
31
|
-
→ HTTP 200 FREE "📄 The Future of Machine Payments
|
|
31
|
+
→ HTTP 200 FREE "📄 The Future of Machine Payments - full article text…"
|
|
32
32
|
Humans are never charged.
|
|
33
33
|
|
|
34
34
|
② An AI crawler hits the same page (ClaudeBot)
|
|
@@ -38,9 +38,9 @@ agent402-tollbooth — live pay-per-crawl demo
|
|
|
38
38
|
|
|
39
39
|
③ The crawler has no wallet, so it spends CPU instead
|
|
40
40
|
solved in 0.32s (nonce=100208)
|
|
41
|
-
→ HTTP 200 OK (paid via pow) "📄 The Future of Machine Payments
|
|
41
|
+
→ HTTP 200 OK (paid via pow) "📄 The Future of Machine Payments - full article text…"
|
|
42
42
|
|
|
43
|
-
✓ Pay-per-crawl, end to end
|
|
43
|
+
✓ Pay-per-crawl, end to end - humans free, bots pay (USDC or compute).
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
## Install
|
|
@@ -81,12 +81,12 @@ The 402 body advertises both rails:
|
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
A crawler that can't (or won't) pay USDC solves the puzzle and retries with
|
|
84
|
-
`X-Pow-Solution: <token>:<nonce>`
|
|
84
|
+
`X-Pow-Solution: <token>:<nonce>` - sub-second of CPU, single-use, bound to that
|
|
85
85
|
exact URL.
|
|
86
86
|
|
|
87
87
|
## Use it as a reverse proxy (any language/framework)
|
|
88
88
|
|
|
89
|
-
Point it at your existing site
|
|
89
|
+
Point it at your existing site - no code changes there:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
TOLLBOOTH_UPSTREAM=https://your-site.com \
|
|
@@ -97,17 +97,17 @@ npx agent402-tollbooth # listens on :4021, proxies humans free, charges
|
|
|
97
97
|
## Run on the edge (Cloudflare Workers, Next.js, Deno, Bun)
|
|
98
98
|
|
|
99
99
|
The same gate is also built on the Web Crypto + Fetch APIs (`edge.js`), so it runs
|
|
100
|
-
anywhere
|
|
100
|
+
anywhere - no Node required. The gate returns a `402 Response` when the client
|
|
101
101
|
must pay, or `null` to let it through.
|
|
102
102
|
|
|
103
103
|
**Ready-to-deploy templates** (copy a folder, don't assemble from docs):
|
|
104
104
|
|
|
105
|
-
- **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/)
|
|
105
|
+
- **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/) - a ready
|
|
106
106
|
`wrangler.toml` + a 3-step deploy guide (the open pay-per-crawl, on the
|
|
107
107
|
incumbent's own platform).
|
|
108
|
-
- **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/)
|
|
108
|
+
- **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/) - a drop-in
|
|
109
109
|
`middleware.js` + a 3-step deploy guide.
|
|
110
|
-
- **Docker** → [`deploy/docker/`](deploy/docker/)
|
|
110
|
+
- **Docker** → [`deploy/docker/`](deploy/docker/) - a `Dockerfile` +
|
|
111
111
|
`docker-compose.yml` to run the reverse proxy in front of any site with
|
|
112
112
|
`docker compose up -d` (includes the live `/__tollbooth` dashboard).
|
|
113
113
|
|
|
@@ -141,29 +141,38 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)
|
|
|
141
141
|
|
|
142
142
|
> On the edge, pass a stable `secret` (PoW tokens are HMAC-signed). For
|
|
143
143
|
> single-use replay protection across stateless invocations, supply a `store`
|
|
144
|
-
> (e.g. a Cloudflare KV wrapper
|
|
144
|
+
> (e.g. a Cloudflare KV wrapper - the Worker entry wires this for you).
|
|
145
145
|
|
|
146
146
|
## Accepting USDC (x402)
|
|
147
147
|
|
|
148
148
|
The proof-of-work rail works with **zero config**. To also settle real USDC,
|
|
149
|
-
set `payTo` and supply `verifyX402`
|
|
149
|
+
set `payTo` and supply `verifyX402` - wire it to the standard, audited x402
|
|
150
150
|
server stack (`@x402/express` / your facilitator) rather than reinventing
|
|
151
151
|
settlement:
|
|
152
152
|
|
|
153
153
|
```js
|
|
154
|
+
import { createTollbooth, x402VerifierFromExpress } from "agent402-tollbooth";
|
|
154
155
|
import { paymentMiddleware } from "x402-express"; // or @x402/express
|
|
155
156
|
const x402 = paymentMiddleware(/* your wallet + facilitator config */);
|
|
156
157
|
|
|
157
158
|
app.use(createTollbooth({
|
|
158
159
|
payTo: "0xYourWallet",
|
|
159
160
|
network: "base",
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
// First-party verifier that OWNS timeout + cancellation: it honors the
|
|
162
|
+
// AbortSignal the gate passes on the SECOND argument (opts.signal), so a slow
|
|
163
|
+
// middleware can't hang the gate or settle after the gate already returned 402.
|
|
164
|
+
verifyX402: x402VerifierFromExpress(x402, { timeoutMs: 9000 }),
|
|
163
165
|
}));
|
|
164
166
|
```
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
The gate calls `verifyX402(req, opts)` and puts an `AbortSignal` on `opts.signal`
|
|
169
|
+
(aborted when its `TOLLBOOTH_VERIFY_TIMEOUT_MS` fires). `x402VerifierFromExpress`
|
|
170
|
+
honors it and stops treating a late result as valid. Note @x402/express settles
|
|
171
|
+
by broadcasting and has no cancel hook, so keep the timeout above your settle
|
|
172
|
+
latency (the abort is a backstop); a verifier that ignores the signal entirely
|
|
173
|
+
can still charge a buyer who already received a 402. If you write your own
|
|
174
|
+
verifier instead of using the helper, destructure `opts.signal` and reject/stop
|
|
175
|
+
on abort. (PoW is checked first, so an agent without a wallet always has a free path.)
|
|
167
176
|
|
|
168
177
|
## Configuration
|
|
169
178
|
|
|
@@ -185,11 +194,40 @@ app.use(createTollbooth({
|
|
|
185
194
|
| `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
|
|
186
195
|
| `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
|
|
187
196
|
| `statsSink` | in-memory | Durable stats backend. Built-ins: `memorySink`, `kvStatsSink(kv)`, `httpStatsSink(url)`. |
|
|
197
|
+
| `replayStore` | **this process's memory** | Shared single-use record for solved proof-of-work tokens. Required for multi-worker / multi-instance / serverless. Built-ins: `sqliteReplayStore(db)`, `redisReplayStore(client)`. See below. |
|
|
188
198
|
|
|
189
|
-
Environment variables
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
### Environment variables
|
|
200
|
+
|
|
201
|
+
Read by the bundled proxy / Express entry point (`index.js`):
|
|
202
|
+
|
|
203
|
+
| env | default | meaning |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `TOLLBOOTH_UPSTREAM` | – | Origin the built-in reverse proxy forwards to |
|
|
206
|
+
| `TOLLBOOTH_PAYTO` | – | Wallet address; set to advertise a USDC x402 quote |
|
|
207
|
+
| `TOLLBOOTH_PRICE` | `"$0.001"` | Advertised price per request |
|
|
208
|
+
| `TOLLBOOTH_NETWORK` | `"base"` | x402 network |
|
|
209
|
+
| `TOLLBOOTH_ASSET` | `"USDC"` | Asset symbol in the quote (`USDG` charges in USDG on Robinhood Chain) |
|
|
210
|
+
| `TOLLBOOTH_POW_BITS` | `18` | Proof-of-work difficulty in leading zero bits |
|
|
211
|
+
| `TOLLBOOTH_MODE` | `"bots"` | Who pays: `bots` · `all` · `strict` |
|
|
212
|
+
| `TOLLBOOTH_ADAPTIVE` | `false` | Raise PoW difficulty as charged-request load climbs |
|
|
213
|
+
| `TOLLBOOTH_ADAPTIVE_PER_BIT` | `300` | +1 difficulty bit per N charged requests/min |
|
|
214
|
+
| `TOLLBOOTH_SECRET` | random | HMAC secret binding PoW challenges (set it to survive restarts / run multiple instances) |
|
|
215
|
+
| `TOLLBOOTH_REPLAY_SQLITE` | – | Path to a SQLite file every process shares as the single-use PoW record. Set it whenever more than one process serves the same `TOLLBOOTH_SECRET`. Needs Node 22.5+ (built-in `node:sqlite`) or an installed `better-sqlite3`; refuses to start if neither can open the file |
|
|
216
|
+
| `TOLLBOOTH_RESOURCE_BASE` | `TOLLBOOTH_UPSTREAM` | Absolute base used for the `resource` field / PoW binding |
|
|
217
|
+
| `TOLLBOOTH_VERIFY_TIMEOUT_MS` | `10000` | Abort an x402 settlement check after this long |
|
|
218
|
+
| `TOLLBOOTH_OBSERVE` | `false` | Observe-only: classify and count, never 402 |
|
|
219
|
+
| `TOLLBOOTH_ADMIN_TOKEN` | – | Token gating the `/__tollbooth` dashboard **and** `/__tollbooth/stats`. Unset logs a warning and leaves the dashboard publicly reachable (aggregate counts only) |
|
|
220
|
+
| `TOLLBOOTH_STATS_TOKEN` | – | Legacy token gating `/__tollbooth/stats` only |
|
|
221
|
+
| `PORT` | `4021` | Listen port |
|
|
222
|
+
|
|
223
|
+
Cloudflare Worker only (`worker.js`, bound in `wrangler.toml`):
|
|
224
|
+
|
|
225
|
+
| binding / env | meaning |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `TOLLBOOTH_REPLAY` | Durable Object binding giving **atomic**, strict single-use PoW replay protection across isolates. Required in enforcing mode |
|
|
228
|
+
| `TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY` | `"true"` explicitly accepts non-atomic (KV or per-isolate) replay protection instead of a Durable Object. Without it, enforcing mode refuses to start |
|
|
229
|
+
| `TOLLBOOTH_KV` | KV namespace for durable stats |
|
|
230
|
+
| `TOLLBOOTH_STATS_BUCKET` | Stats bucket name within `TOLLBOOTH_KV` (default `"default"`) |
|
|
193
231
|
|
|
194
232
|
## How it decides who pays
|
|
195
233
|
|
|
@@ -199,25 +237,25 @@ Google-Extended, Amazonbot, …). Classic search indexers (Googlebot, Bingbot) a
|
|
|
199
237
|
intentionally **not** charged so your SEO indexing stays free.
|
|
200
238
|
|
|
201
239
|
**Don't want to play whack-a-mole with bot detection?** That's the point of the
|
|
202
|
-
other modes
|
|
240
|
+
other modes - you stop trying to *identify* bots and instead make access *cost
|
|
203
241
|
something*:
|
|
204
242
|
- `mode: "all"` charges every client (except a `free()` match). A "more
|
|
205
|
-
sophisticated" bot gains nothing by disguising itself
|
|
243
|
+
sophisticated" bot gains nothing by disguising itself - everyone pays or solves
|
|
206
244
|
a proof-of-work.
|
|
207
245
|
- `mode: "strict"` charges anything that isn't a real-browser request (browser-like
|
|
208
246
|
UA **and** an HTML `Accept`), letting genuine human page-loads through free.
|
|
209
|
-
Heads-up: that's a heuristic, not a security boundary
|
|
247
|
+
Heads-up: that's a heuristic, not a security boundary - a bot that sets
|
|
210
248
|
`User-Agent: Mozilla/5.0 …` + `Accept: text/html` gets the same free pass a
|
|
211
249
|
human gets. Use `mode: "all"` (or your own `charge:` predicate) for hard
|
|
212
250
|
guarantees.
|
|
213
251
|
- `adaptive: true` makes proof-of-work **harder as load climbs**, so a high-volume
|
|
214
|
-
scraper pays escalating CPU per request regardless of how it looks
|
|
252
|
+
scraper pays escalating CPU per request regardless of how it looks - detection is
|
|
215
253
|
cat-and-mouse, economics isn't.
|
|
216
254
|
|
|
217
255
|
## Observe before charging
|
|
218
256
|
|
|
219
257
|
Don't want to flip a meter on cold? **Run the gate in observe-only mode for a
|
|
220
|
-
week first**
|
|
258
|
+
week first** - every request is still classified (bot vs. human) and counted,
|
|
221
259
|
but nothing ever gets a 402:
|
|
222
260
|
|
|
223
261
|
```js
|
|
@@ -226,8 +264,8 @@ app.use(createTollbooth({ observe: true })); // or: TOLLBOOTH_OBSERVE=true
|
|
|
226
264
|
|
|
227
265
|
On the edge / Cloudflare Worker / Next.js: set `TOLLBOOTH_OBSERVE=true` in env.
|
|
228
266
|
|
|
229
|
-
The dashboard grows a **"Would charge"** counter so you can show your team
|
|
230
|
-
or your client
|
|
267
|
+
The dashboard grows a **"Would charge"** counter so you can show your team -
|
|
268
|
+
or your client - exactly how much of their traffic is AI bots **before** you
|
|
231
269
|
start returning 402s. Removing the flag flips on enforcement with no other
|
|
232
270
|
changes. Bots see a `X-Tollbooth-Observed: would-charge` header in observe mode
|
|
233
271
|
(handy for log filtering); humans see nothing.
|
|
@@ -240,12 +278,12 @@ The middleware keeps aggregate counters (no per-request data):
|
|
|
240
278
|
- `gate.flush()` → flush any buffered deltas to the durable sink (call inside `ctx.waitUntil` on edge runtimes).
|
|
241
279
|
|
|
242
280
|
The reverse-proxy CLI exposes them as JSON at **`/__tollbooth/stats`** and as a
|
|
243
|
-
live **dashboard at `/__tollbooth`**
|
|
281
|
+
live **dashboard at `/__tollbooth`** - requests, how many were charged,
|
|
244
282
|
proof-of-work solves, USDC collected, and what share of your traffic is bots.
|
|
245
283
|
|
|
246
284
|
## Durable stats (survive restart, aggregate across instances)
|
|
247
285
|
|
|
248
|
-
By default, stats live in process memory
|
|
286
|
+
By default, stats live in process memory - fine for single-instance Node,
|
|
249
287
|
useless across multiple replicas or on the edge. Pass a `statsSink` to make
|
|
250
288
|
them survive:
|
|
251
289
|
|
|
@@ -272,7 +310,7 @@ app.use(createTollbooth({
|
|
|
272
310
|
}));
|
|
273
311
|
```
|
|
274
312
|
|
|
275
|
-
Sink interface (build your own
|
|
313
|
+
Sink interface (build your own - e.g. a Cloudflare Durable Object for strict
|
|
276
314
|
consistency):
|
|
277
315
|
|
|
278
316
|
```ts
|
|
@@ -283,6 +321,88 @@ type StatsSink = {
|
|
|
283
321
|
};
|
|
284
322
|
```
|
|
285
323
|
|
|
324
|
+
## Single-use proof-of-work across workers and instances (`replayStore`)
|
|
325
|
+
|
|
326
|
+
**The default single-use record lives in ONE PROCESS'S MEMORY.** Read that
|
|
327
|
+
sentence twice if you run more than one process, because the two settings
|
|
328
|
+
interact:
|
|
329
|
+
|
|
330
|
+
- A stable `TOLLBOOTH_SECRET` is what makes a token minted by worker 1 verify on
|
|
331
|
+
worker 2. Without it, multi-process deploys reject every solution.
|
|
332
|
+
- With it, and with no shared replay store, each process keeps its own
|
|
333
|
+
"already used" list. One 18-bit solve is then redeemable **once per process**
|
|
334
|
+
inside the token's 5-minute TTL, and again after a worker recycles. Four
|
|
335
|
+
workers means four requests for one solve.
|
|
336
|
+
|
|
337
|
+
Pass a `replayStore` and every process claims against the same record:
|
|
338
|
+
|
|
339
|
+
```js
|
|
340
|
+
// Several Node workers on one host (cluster, pm2, one container with N processes).
|
|
341
|
+
import Database from "better-sqlite3"; // or node:sqlite on Node 22.5+
|
|
342
|
+
import { createTollbooth, sqliteReplayStore } from "agent402-tollbooth";
|
|
343
|
+
|
|
344
|
+
const db = new Database("/var/lib/tollbooth/replay.db");
|
|
345
|
+
db.pragma("journal_mode = WAL"); // concurrent writers
|
|
346
|
+
db.pragma("busy_timeout = 5000"); // wait for the write lock instead of throwing
|
|
347
|
+
|
|
348
|
+
app.use(createTollbooth({ replayStore: sqliteReplayStore(db) }));
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
// Instances across hosts, or a serverless runtime that can reach Redis.
|
|
353
|
+
import { createClient } from "redis";
|
|
354
|
+
import { createTollbooth, redisReplayStore } from "agent402-tollbooth";
|
|
355
|
+
|
|
356
|
+
const redis = createClient({ url: process.env.REDIS_URL });
|
|
357
|
+
await redis.connect();
|
|
358
|
+
app.use(createTollbooth({ replayStore: redisReplayStore(redis) }));
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`redisReplayStore` also accepts an ioredis client (their `SET` signatures differ;
|
|
362
|
+
the factory detects which one it was handed). Neither driver is a dependency of
|
|
363
|
+
this package: you pass a client you already opened, so the tollbooth still
|
|
364
|
+
installs with nothing but Express.
|
|
365
|
+
|
|
366
|
+
The bundled reverse proxy takes the SQLite path from the environment, so the CLI
|
|
367
|
+
needs no code:
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
TOLLBOOTH_SECRET=$(openssl rand -hex 32) \
|
|
371
|
+
TOLLBOOTH_REPLAY_SQLITE=/var/lib/tollbooth/replay.db \
|
|
372
|
+
TOLLBOOTH_UPSTREAM=https://your-origin.example \
|
|
373
|
+
npx agent402-tollbooth
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Store interface (build your own against Postgres, DynamoDB, a Durable Object -
|
|
377
|
+
this is the same contract the edge gate's `store` option uses, so an
|
|
378
|
+
implementation ports between the two unchanged):
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
type ReplayStore = {
|
|
382
|
+
// MUST be atomic: true only the first time this token is seen, false after.
|
|
383
|
+
claim(token: string, expiresAtMs: number): boolean | Promise<boolean>;
|
|
384
|
+
};
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Three things worth knowing before you write one:
|
|
388
|
+
|
|
389
|
+
- **It may be async.** `claim` can return a promise; `verify()` then returns a
|
|
390
|
+
promise too, and the gate awaits it. A synchronous store keeps the gate
|
|
391
|
+
synchronous, so existing single-process deploys are unaffected.
|
|
392
|
+
- **A throw is a refusal, not a pass.** If `claim` throws or rejects, the gate
|
|
393
|
+
answers `402` with `X-Pow-Error: replay store unavailable`. Let your store
|
|
394
|
+
throw when its backend is unreachable - guessing "probably unused" would hand
|
|
395
|
+
out exactly the free passes the store exists to stop.
|
|
396
|
+
- **Non-atomic is not enough.** A separate "read, then write" pair lets two
|
|
397
|
+
concurrent redemptions of one token both see "unseen" and both pass. SQLite's
|
|
398
|
+
`INSERT OR IGNORE` on a primary key and Redis `SET NX` are atomic; a plain
|
|
399
|
+
eventually-consistent key/value `get` + `put` is not.
|
|
400
|
+
|
|
401
|
+
Scope note: the SQLite store's guarantee is per **database file**, which covers
|
|
402
|
+
many processes sharing a disk. It does not cover hosts sharing a network
|
|
403
|
+
filesystem (SQLite locking is not reliable there) - use Redis or Postgres for
|
|
404
|
+
that shape.
|
|
405
|
+
|
|
286
406
|
## Edge analytics (Cloudflare Worker / Next.js)
|
|
287
407
|
|
|
288
408
|
The Cloudflare Worker entry (`worker.js`) auto-mounts both the dashboard and
|
|
@@ -292,7 +412,7 @@ JSON endpoint, BEFORE the gate so they're never paywalled:
|
|
|
292
412
|
- **`/__tollbooth/stats`** → JSON snapshot (gate with `TOLLBOOTH_STATS_TOKEN` for bearer-auth)
|
|
293
413
|
|
|
294
414
|
With a `TOLLBOOTH_KV` namespace bound, the stats aggregate across all isolates
|
|
295
|
-
of all Cloudflare colos serving the Worker
|
|
415
|
+
of all Cloudflare colos serving the Worker - one consistent view.
|
|
296
416
|
|
|
297
417
|
On Next.js / Vercel Edge, middleware can't mount dashboards itself (it'd gate
|
|
298
418
|
them), so a companion **route handler** at `app/__tollbooth/stats/route.js`
|
|
@@ -303,16 +423,23 @@ as drop-in copyable snippets.
|
|
|
303
423
|
## Production checklist (read this)
|
|
304
424
|
|
|
305
425
|
- **Set a stable `TOLLBOOTH_SECRET`.** Required for any multi-process/clustered
|
|
306
|
-
Node deploy and for all edge deploys
|
|
426
|
+
Node deploy and for all edge deploys - without it, proof-of-work tokens use a
|
|
307
427
|
random per-process secret and are rejected across restarts/workers/isolates.
|
|
308
|
-
- **
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
428
|
+
- **If more than one process shares that secret, supply a `replayStore`.** The
|
|
429
|
+
default single-use record is per process, so a stable secret plus no shared
|
|
430
|
+
store means one solve buys one free request per worker within its TTL. Node:
|
|
431
|
+
`replayStore: sqliteReplayStore(db)` / `redisReplayStore(client)`, or
|
|
432
|
+
`TOLLBOOTH_REPLAY_SQLITE=<path>` for the bundled proxy
|
|
433
|
+
([details](#single-use-proof-of-work-across-workers-and-instances-replaystore)).
|
|
434
|
+
- **For serverless/edge, supply a durable replay `store`** (bind a Durable Object
|
|
435
|
+
as `TOLLBOOTH_REPLAY` for atomic claims; KV is eventually consistent and the
|
|
436
|
+
Worker entry refuses to enforce on it without an explicit override). The
|
|
437
|
+
in-memory default is per-isolate, so a solved token could otherwise be reused
|
|
438
|
+
across isolates within its TTL.
|
|
312
439
|
- **The reverse proxy pins the host** to your configured upstream (a client can't
|
|
313
440
|
redirect it elsewhere) and **strips client-forged trust/forwarding headers**
|
|
314
441
|
(`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
|
|
315
|
-
- **UA matching is the default, not a security boundary**
|
|
442
|
+
- **UA matching is the default, not a security boundary** - a bot can forge a
|
|
316
443
|
human UA to get the *same free access a human gets* (it gains nothing more). To
|
|
317
444
|
stop relying on detection entirely, use `mode: "all"` / `mode: "strict"`, and
|
|
318
445
|
turn on `adaptive` so high-volume abuse pays escalating proof-of-work.
|
|
@@ -320,8 +447,9 @@ as drop-in copyable snippets.
|
|
|
320
447
|
## Notes
|
|
321
448
|
|
|
322
449
|
- Proof-of-work tokens are HMAC-signed, expiry-checked, single-use, and bound to
|
|
323
|
-
the exact resource (path + query, dots and all)
|
|
324
|
-
be replayed or reused on another.
|
|
450
|
+
the exact resource (path + query, dots and all) - a solution for one URL can't
|
|
451
|
+
be replayed or reused on another. Single-use is recorded **per process** unless
|
|
452
|
+
you pass a `replayStore`.
|
|
325
453
|
- MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
|
326
454
|
|
|
327
455
|
## Charge in USDG on Robinhood Chain
|
package/edge.js
CHANGED
|
@@ -101,7 +101,14 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
|
|
|
101
101
|
if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
102
102
|
// Atomically claim single-use only AFTER the work is validated, so an invalid
|
|
103
103
|
// attempt never consumes the token and concurrent dupes can't both pass.
|
|
104
|
-
if
|
|
104
|
+
// F05: FAIL CLOSED — if the claim store is unavailable, deny rather than let
|
|
105
|
+
// the exception propagate (or, worse, grant). Strict single-use needs an
|
|
106
|
+
// atomic store (Durable Object); see worker.js durableObjectStore + the
|
|
107
|
+
// wrangler template. A get-then-put KV store is best-effort only.
|
|
108
|
+
let claimed;
|
|
109
|
+
try { claimed = await used.claim(token, Number(expStr)); }
|
|
110
|
+
catch { return { ok: false, reason: "replay store unavailable" }; }
|
|
111
|
+
if (!claimed) return { ok: false, reason: "already used" };
|
|
105
112
|
return { ok: true };
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -177,11 +184,19 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
177
184
|
if (!shouldCharge(request)) { incr("freeAllowed"); return null; }
|
|
178
185
|
if (observe) { incr("wouldCharge"); return null; }
|
|
179
186
|
const u = new URL(request.url);
|
|
180
|
-
|
|
187
|
+
// F18: bind the canonical ORIGIN, not just path+query — otherwise a proof
|
|
188
|
+
// for /x transfers to any other site sharing the secret. On the edge the
|
|
189
|
+
// request URL already carries the real origin; prefer an explicit
|
|
190
|
+
// resourceBaseUrl when configured.
|
|
191
|
+
const origin = resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : u.origin;
|
|
192
|
+
const resource = origin + u.pathname + u.search;
|
|
193
|
+
// Additionally bind the HTTP METHOD in the PoW challenge (x402 `resource`
|
|
194
|
+
// stays a plain URL for wire compatibility).
|
|
195
|
+
const powResource = `${request.method || "GET"} ${resource}`;
|
|
181
196
|
|
|
182
197
|
const sol = request.headers.get("x-pow-solution");
|
|
183
198
|
if (powEngine && sol) {
|
|
184
|
-
const r = await powEngine.verify(sol,
|
|
199
|
+
const r = await powEngine.verify(sol, powResource);
|
|
185
200
|
if (r.ok) { incr("powSolved"); return null; }
|
|
186
201
|
}
|
|
187
202
|
|
|
@@ -191,7 +206,7 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
191
206
|
message,
|
|
192
207
|
accepts: payTo ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
|
|
193
208
|
};
|
|
194
|
-
if (powEngine) body.proofOfWork = await powEngine.challenge(
|
|
209
|
+
if (powEngine) body.proofOfWork = await powEngine.challenge(powResource);
|
|
195
210
|
return new Response(JSON.stringify(body), {
|
|
196
211
|
status: 402,
|
|
197
212
|
headers: { "content-type": "application/json", "x-tollbooth": "pay-per-crawl" },
|
package/index.js
CHANGED
|
@@ -21,8 +21,56 @@ import { memorySink } from "./sinks.js";
|
|
|
21
21
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
22
22
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
23
23
|
export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
|
|
24
|
+
export { sqliteReplayStore, redisReplayStore } from "./replay.js";
|
|
24
25
|
|
|
25
26
|
const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
|
|
27
|
+
|
|
28
|
+
// P1.5 / FR4-10: a first-party `verifyX402` built from a standard @x402/express
|
|
29
|
+
// payment middleware, that OWNS timeout + cancellation. The gate calls
|
|
30
|
+
// verifyX402(req, opts) and puts an AbortSignal on `opts.signal` (aborted when
|
|
31
|
+
// the gate's own verify timeout fires); this wrapper honors it — once the signal
|
|
32
|
+
// aborts (or its own optional backstop timeout fires) it settles the verify
|
|
33
|
+
// promise immediately and reports NOT-verified, so a slow middleware can't leave
|
|
34
|
+
// the gate hanging or produce a charged-then-denied result after the gate has
|
|
35
|
+
// returned 402. Grants on next(); denies when the middleware writes a 402.
|
|
36
|
+
//
|
|
37
|
+
// LIMITATION (documented): @x402/express settles by BROADCASTING and exposes no
|
|
38
|
+
// cancel hook, so this wrapper cannot abort an on-chain settle already in flight.
|
|
39
|
+
// Keep TOLLBOOTH_VERIFY_TIMEOUT_MS comfortably above your settle latency so the
|
|
40
|
+
// abort is a backstop, not the normal path; or front it with a facilitator that
|
|
41
|
+
// supports cancellation.
|
|
42
|
+
export function x402VerifierFromExpress(paymentMiddleware, { timeoutMs } = {}) {
|
|
43
|
+
if (typeof paymentMiddleware !== "function") {
|
|
44
|
+
throw new TypeError("x402VerifierFromExpress: paymentMiddleware must be a function");
|
|
45
|
+
}
|
|
46
|
+
return (req, opts = {}) => new Promise((resolve, reject) => {
|
|
47
|
+
const signal = opts?.signal;
|
|
48
|
+
let done = false;
|
|
49
|
+
let timer = null;
|
|
50
|
+
const finish = (fn, v) => {
|
|
51
|
+
if (done) return;
|
|
52
|
+
done = true;
|
|
53
|
+
if (timer) clearTimeout(timer);
|
|
54
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
55
|
+
fn(v);
|
|
56
|
+
};
|
|
57
|
+
const onAbort = () => finish(resolve, false); // gate timed out → treat as not verified
|
|
58
|
+
if (signal?.aborted) return finish(resolve, false);
|
|
59
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
60
|
+
if (timeoutMs > 0) timer = setTimeout(() => finish(resolve, false), timeoutMs);
|
|
61
|
+
// Minimal response shim: the middleware calls next() to GRANT, or writes a
|
|
62
|
+
// 402 via status/json/send/end to DENY.
|
|
63
|
+
const res = {
|
|
64
|
+
setHeader() { return this; }, getHeader() {}, removeHeader() { return this; }, getHeaders() { return {}; },
|
|
65
|
+
status() { return this; }, json() { finish(resolve, false); return this; },
|
|
66
|
+
send() { finish(resolve, false); return this; }, end() { finish(resolve, false); return this; },
|
|
67
|
+
writeHead() { return this; }, write() { return true; }, flushHeaders() {},
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
Promise.resolve(paymentMiddleware(req, res, () => finish(resolve, true))).catch((e) => finish(reject, e));
|
|
71
|
+
} catch (e) { finish(reject, e); }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
26
74
|
// Headers a client must never be able to forge through the proxy: the gate's own
|
|
27
75
|
// trust signals and forwarding/hop-by-hop headers.
|
|
28
76
|
const STRIP_INBOUND = new Set([
|
|
@@ -40,6 +88,8 @@ const STRIP_INBOUND = new Set([
|
|
|
40
88
|
* @param {string} [config.asset="USDC"] stablecoin symbol in the quote (USDG on Robinhood Chain)
|
|
41
89
|
* @param {boolean} [config.pow=true] enable the free proof-of-work rail
|
|
42
90
|
* @param {number} [config.powDifficulty] PoW difficulty in leading zero bits
|
|
91
|
+
* @param {object} [config.replayStore] shared single-use PoW backend, `{ claim(token, expiresAtMs) => boolean|Promise<boolean> }`.
|
|
92
|
+
* Default is THIS PROCESS'S memory; required for multi-worker / multi-instance / serverless. See replay.js.
|
|
43
93
|
* @param {string[]} [config.botUserAgents] user-agents to charge (default: AI_BOTS)
|
|
44
94
|
* @param {(req)=>boolean} [config.charge] custom "should this client pay?" predicate
|
|
45
95
|
* @param {(req)=>boolean} [config.free] custom force-allow predicate (wins over charge)
|
|
@@ -61,6 +111,12 @@ export function createTollbooth(config = {}) {
|
|
|
61
111
|
pow = true,
|
|
62
112
|
powDifficulty,
|
|
63
113
|
powSecret,
|
|
114
|
+
// Shared single-use record for solved proof-of-work tokens. Unset means
|
|
115
|
+
// per-process memory, which is only single-use within ONE process: a stable
|
|
116
|
+
// TOLLBOOTH_SECRET (needed so tokens verify across workers at all) then makes
|
|
117
|
+
// one solve redeemable once per worker inside the token TTL. Pass a store
|
|
118
|
+
// from replay.js for any deploy with more than one process.
|
|
119
|
+
replayStore,
|
|
64
120
|
botUserAgents = AI_BOTS,
|
|
65
121
|
// Who pays. Default "bots" = the original behavior (charge AI crawler UAs).
|
|
66
122
|
// "all" — charge every client except a `free()` match (UA detection is
|
|
@@ -90,7 +146,7 @@ export function createTollbooth(config = {}) {
|
|
|
90
146
|
} = config;
|
|
91
147
|
|
|
92
148
|
const isBot = makeBotMatcher(botUserAgents);
|
|
93
|
-
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
149
|
+
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret, replayStore }) : null;
|
|
94
150
|
|
|
95
151
|
// Passive analytics — never affects request handling, just counts what happens.
|
|
96
152
|
// `mem` is an always-on in-process mirror so `.stats()` stays synchronous for
|
|
@@ -139,7 +195,15 @@ export function createTollbooth(config = {}) {
|
|
|
139
195
|
const raw = req.originalUrl || req.url || "/";
|
|
140
196
|
let pathAndSearch = raw;
|
|
141
197
|
try { const u = new URL(raw, "http://internal.invalid"); pathAndSearch = u.pathname + u.search; } catch { /* keep raw */ }
|
|
142
|
-
|
|
198
|
+
// F18: bind the canonical ORIGIN, not just path+query, so a proof minted for
|
|
199
|
+
// /x on site A cannot be replayed on site B. Prefer a configured
|
|
200
|
+
// resourceBaseUrl; otherwise derive the origin from the request host (same
|
|
201
|
+
// site => same origin; a different host => a different resource). Reusing one
|
|
202
|
+
// TOLLBOOTH_SECRET across sites should ALSO use a unique secret per site.
|
|
203
|
+
const origin = resourceBaseUrl
|
|
204
|
+
? resourceBaseUrl.replace(/\/$/, "")
|
|
205
|
+
: `${req.protocol || "https"}://${String(req.headers?.host || (req.get && req.get("host")) || "").toLowerCase()}`;
|
|
206
|
+
return origin + pathAndSearch;
|
|
143
207
|
};
|
|
144
208
|
|
|
145
209
|
function tollbooth(req, res, next) {
|
|
@@ -149,6 +213,10 @@ export function createTollbooth(config = {}) {
|
|
|
149
213
|
// measure bot traffic on a live site before turning on enforcement.
|
|
150
214
|
if (observe) { incr("wouldCharge"); res.setHeader("X-Tollbooth-Observed", "would-charge"); return next(); }
|
|
151
215
|
const resource = resourceOf(req);
|
|
216
|
+
// F18: the PoW challenge additionally binds the HTTP METHOD, so a proof
|
|
217
|
+
// solved for GET /x can't be replayed against POST /x. The x402 `resource`
|
|
218
|
+
// stays a plain URL (below) for wire compatibility.
|
|
219
|
+
const powResource = `${(req.method || "GET").toUpperCase()} ${resource}`;
|
|
152
220
|
|
|
153
221
|
const send402 = (extra = {}) => {
|
|
154
222
|
incr("charged");
|
|
@@ -161,33 +229,60 @@ export function createTollbooth(config = {}) {
|
|
|
161
229
|
: [],
|
|
162
230
|
...extra,
|
|
163
231
|
};
|
|
164
|
-
if (powEngine) body.proofOfWork = powEngine.challenge(
|
|
232
|
+
if (powEngine) body.proofOfWork = powEngine.challenge(powResource, difficultyNow());
|
|
165
233
|
res.status(402).json(body);
|
|
166
234
|
};
|
|
167
235
|
|
|
236
|
+
// Paid rail: x402 (USDC). Settlement verification is operator-supplied so
|
|
237
|
+
// we reuse the standard, audited x402 stack rather than reinvent it. Reached
|
|
238
|
+
// either directly or after the free rail declined, so it lives in a function
|
|
239
|
+
// the (possibly async) proof-of-work branch can hand control back to.
|
|
240
|
+
const paidRail = () => {
|
|
241
|
+
const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
|
|
242
|
+
if (payTo && typeof verifyX402 === "function" && payHeader) {
|
|
243
|
+
// Bound verification time so a slow/hung verifier can't exhaust resources.
|
|
244
|
+
// F19: pass an AbortSignal and ABORT it on timeout, so a slow verifier that
|
|
245
|
+
// also SETTLES cannot move money after we have already returned 402 (a
|
|
246
|
+
// charged denial). A pure-verification callback may ignore the signal; a
|
|
247
|
+
// settling one MUST cancel/drain in-flight work on it before returning.
|
|
248
|
+
const ac = new AbortController();
|
|
249
|
+
const timeout = new Promise((resolve) => setTimeout(() => { ac.abort(); resolve(false); }, VERIFY_TIMEOUT_MS));
|
|
250
|
+
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource, signal: ac.signal })), timeout])
|
|
251
|
+
.then((ok) => {
|
|
252
|
+
ac.abort(); // verification resolved: cancel anything still in flight
|
|
253
|
+
if (ok) { incr("x402Paid"); res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
254
|
+
send402();
|
|
255
|
+
})
|
|
256
|
+
.catch(() => { ac.abort(); res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return send402();
|
|
260
|
+
};
|
|
261
|
+
|
|
168
262
|
// Free rail: proof-of-work.
|
|
169
263
|
const powHeader = req.headers["x-pow-solution"];
|
|
170
264
|
if (powEngine && powHeader) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
})
|
|
187
|
-
|
|
265
|
+
const afterPow = (r) => {
|
|
266
|
+
if (r.ok) { incr("powSolved"); res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
|
|
267
|
+
res.setHeader("X-Pow-Error", r.reason);
|
|
268
|
+
return paidRail();
|
|
269
|
+
};
|
|
270
|
+
// pow.verify is synchronous with the default in-process replay store and
|
|
271
|
+
// returns a promise when a shared store (SQLite/Redis) answers with one.
|
|
272
|
+
// Branch on the shape instead of awaiting unconditionally: the default path
|
|
273
|
+
// must keep resolving inside this tick, because a synchronous caller (and
|
|
274
|
+
// our own single-process tests) would otherwise see the middleware return
|
|
275
|
+
// before it has decided anything.
|
|
276
|
+
const r = powEngine.verify(powHeader, powResource);
|
|
277
|
+
if (r && typeof r.then === "function") {
|
|
278
|
+
// A rejected verify would leave the request hanging with no response, so
|
|
279
|
+
// it degrades to the same refusal the store-throw path produces.
|
|
280
|
+
return r.then(afterPow, () => afterPow({ ok: false, reason: "replay store unavailable" }));
|
|
281
|
+
}
|
|
282
|
+
return afterPow(r);
|
|
188
283
|
}
|
|
189
284
|
|
|
190
|
-
return
|
|
285
|
+
return paidRail();
|
|
191
286
|
}
|
|
192
287
|
|
|
193
288
|
tollbooth.shouldCharge = shouldCharge;
|
|
@@ -262,11 +357,68 @@ async function startCli() {
|
|
|
262
357
|
if (!_secret) {
|
|
263
358
|
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.");
|
|
264
359
|
} else if (/^change-me/i.test(_secret) || _secret === "change-me-to-a-long-random-string") {
|
|
265
|
-
|
|
360
|
+
// FATAL, not a warning. The placeholder is published in our own deploy
|
|
361
|
+
// template, so anyone can read it, mint a token with difficulty 0 and walk
|
|
362
|
+
// through the gate without doing any work - the operator believes they are
|
|
363
|
+
// charging while everything is free. A warning scrolls past in a container
|
|
364
|
+
// log; refusing to start cannot be missed, and it fails CLOSED.
|
|
365
|
+
console.error("✖ TOLLBOOTH_SECRET is the public placeholder from the deploy template. It is readable by anyone, so proof-of-work tokens can be forged and your gate bypassed for free. Refusing to start. Generate a real secret: openssl rand -hex 32");
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
// A stable secret makes one solved token verifiable by EVERY process sharing
|
|
369
|
+
// it, and the single-use record defaults to this process's memory, so behind a
|
|
370
|
+
// load balancer, or under `cluster`, one solve buys a free request per process
|
|
371
|
+
// for the token's lifetime. TOLLBOOTH_REPLAY_SQLITE points every process at one
|
|
372
|
+
// claim table and closes that. Opt-in, because a single-process proxy (the
|
|
373
|
+
// common case for this CLI) needs no file and no extra dependency.
|
|
374
|
+
let replayStore;
|
|
375
|
+
const replayPath = process.env.TOLLBOOTH_REPLAY_SQLITE;
|
|
376
|
+
if (replayPath) {
|
|
377
|
+
const { sqliteReplayStore } = await import("./replay.js");
|
|
378
|
+
// node:sqlite is built in (Node 22.5+); better-sqlite3 covers older Node and
|
|
379
|
+
// is what many operators already have installed. Neither is a dependency of
|
|
380
|
+
// this package: the store is opt-in, so its driver is too.
|
|
381
|
+
let db = null;
|
|
382
|
+
try {
|
|
383
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
384
|
+
db = new DatabaseSync(replayPath);
|
|
385
|
+
} catch {
|
|
386
|
+
try {
|
|
387
|
+
const { default: Database } = await import("better-sqlite3");
|
|
388
|
+
db = new Database(replayPath);
|
|
389
|
+
} catch { db = null; }
|
|
390
|
+
}
|
|
391
|
+
if (!db) {
|
|
392
|
+
// The operator asked for a shared store; starting without one would leave
|
|
393
|
+
// the replay hole open while the config says it is closed. Fail closed.
|
|
394
|
+
console.error(`✖ TOLLBOOTH_REPLAY_SQLITE=${replayPath} was set but no SQLite driver could be opened. Run Node 22.5+ (built-in node:sqlite) or install better-sqlite3. Refusing to start.`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
// busy_timeout matters more than it looks: a claim that hits SQLITE_BUSY
|
|
399
|
+
// throws, pow.js fails closed on a throw, and the refusal lands on a
|
|
400
|
+
// crawler that did the work. WAL is best-effort because converting a
|
|
401
|
+
// brand-new file needs an exclusive lock a sibling worker may hold
|
|
402
|
+
// (measured: 2 of 160 simultaneous boots lost that lock); the winner writes
|
|
403
|
+
// the mode into the file, so the losers inherit it and nothing is lost.
|
|
404
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
405
|
+
try { db.exec("PRAGMA journal_mode = WAL"); } catch { /* a sibling worker is setting it */ }
|
|
406
|
+
replayStore = sqliteReplayStore(db);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
// Not retried: the failure this actually produces is a permanently broken
|
|
409
|
+
// file (for example an orphaned -wal/-shm left beside a deleted database),
|
|
410
|
+
// which no number of attempts clears. Refuse to start rather than serve
|
|
411
|
+
// with per-process-only replay protection while the config claims otherwise.
|
|
412
|
+
console.error(`✖ TOLLBOOTH_REPLAY_SQLITE=${replayPath} could not be initialised: ${e.message}. If a stale ${replayPath}-wal / -shm pair is present without its database, remove them. Refusing to start.`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
console.log(`shared proof-of-work replay store: sqlite ${replayPath}`);
|
|
416
|
+
} else if (_secret) {
|
|
417
|
+
console.warn("⚠ No shared proof-of-work replay store (TOLLBOOTH_REPLAY_SQLITE unset): solved tokens are single-use PER PROCESS only. Fine for one process; behind a load balancer or under `cluster`, one solve is redeemable once per process within its 5-minute TTL.");
|
|
266
418
|
}
|
|
267
419
|
const { default: express } = await import("express");
|
|
268
420
|
const app = express();
|
|
269
|
-
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
|
421
|
+
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "", replayStore });
|
|
270
422
|
// Operator analytics — aggregate counts only (no per-request data), mounted
|
|
271
423
|
// before the gate so they're always reachable and never themselves charged.
|
|
272
424
|
// Two opt-in admin tokens (legacy `TOLLBOOTH_STATS_TOKEN` covers /stats only;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402 — or USDG on Robinhood Chain — or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy — no Cloudflare, no Stripe, no signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"./worker": "./worker.js",
|
|
15
15
|
"./worker.js": "./worker.js",
|
|
16
16
|
"./dashboard.js": "./dashboard.js",
|
|
17
|
-
"./sinks.js": "./sinks.js"
|
|
17
|
+
"./sinks.js": "./sinks.js",
|
|
18
|
+
"./replay": "./replay.js",
|
|
19
|
+
"./replay.js": "./replay.js"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
20
22
|
"index.js",
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
"pow.js",
|
|
25
27
|
"dashboard.js",
|
|
26
28
|
"sinks.js",
|
|
29
|
+
"replay.js",
|
|
27
30
|
"example.js",
|
|
28
31
|
"demo.js",
|
|
29
32
|
"README.md"
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
"http-402",
|
|
49
52
|
"402-payment-required",
|
|
50
53
|
"proof-of-work",
|
|
54
|
+
"replay-protection",
|
|
51
55
|
"usdc",
|
|
52
56
|
"reverse-proxy",
|
|
53
57
|
"express-middleware",
|
package/pow.js
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
// exact resource they were minted for, expiry-checked, and single-use (a solved
|
|
7
7
|
// token is remembered until it expires). Solving costs the caller CPU; issuing
|
|
8
8
|
// and verifying cost us one hash + one HMAC.
|
|
9
|
+
//
|
|
10
|
+
// Single-use is only as wide as the store that records it. The default store is
|
|
11
|
+
// THIS PROCESS'S memory, which is exactly right for a single-process deploy and
|
|
12
|
+
// insufficient for anything else: a stable TOLLBOOTH_SECRET makes one token
|
|
13
|
+
// verifiable by every worker/instance sharing it, so a single solve would buy
|
|
14
|
+
// one free request PER PROCESS within the token's TTL (and again after a worker
|
|
15
|
+
// recycle). Multi-worker, multi-instance and serverless operators must pass a
|
|
16
|
+
// shared `replayStore` (see replay.js and the README).
|
|
9
17
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
18
|
|
|
11
19
|
const TTL_MS = 5 * 60 * 1000;
|
|
@@ -21,17 +29,52 @@ export function leadingZeroBits(buf) {
|
|
|
21
29
|
return bits;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} [opts]
|
|
34
|
+
* @param {string} [opts.secret] HMAC secret binding challenges to this operator
|
|
35
|
+
* @param {number} [opts.difficulty] leading zero bits a solution must carry
|
|
36
|
+
* @param {number} [opts.ttlMs] challenge lifetime
|
|
37
|
+
* @param {{claim:(token:string,expiresAtMs:number)=>boolean|Promise<boolean>}} [opts.replayStore]
|
|
38
|
+
* Shared single-use backend. `claim` must ATOMICALLY return true only the
|
|
39
|
+
* first time it sees a token, false on every later call. Mirrors the
|
|
40
|
+
* `store` contract of createEdgePow in edge.js (accepted here under that
|
|
41
|
+
* name too, so an edge snippet ports across unchanged). Defaults to an
|
|
42
|
+
* in-process Map: correct for one process, wrong for several.
|
|
43
|
+
*/
|
|
24
44
|
export function createPow({
|
|
25
45
|
secret = process.env.TOLLBOOTH_SECRET || randomBytes(32).toString("hex"),
|
|
26
46
|
difficulty = Number(process.env.TOLLBOOTH_POW_BITS) || 18,
|
|
27
47
|
ttlMs = TTL_MS,
|
|
48
|
+
replayStore,
|
|
49
|
+
store,
|
|
28
50
|
} = {}) {
|
|
29
|
-
const
|
|
30
|
-
|
|
51
|
+
const shared = replayStore || store;
|
|
52
|
+
if (shared && typeof shared.claim !== "function") {
|
|
53
|
+
// Loud at construction rather than silently falling back to per-process
|
|
54
|
+
// memory: an operator who passes a misshapen store believes they have
|
|
55
|
+
// cluster-wide single-use, and a silent fallback would leave the exact hole
|
|
56
|
+
// they configured the store to close.
|
|
57
|
+
throw new TypeError("createPow: replayStore must expose claim(token, expiresAtMs) => boolean | Promise<boolean>");
|
|
58
|
+
}
|
|
59
|
+
const used = new Map(); // token -> expiry(ms); only used by the default store
|
|
60
|
+
// Default replay backend, kept as the default so existing single-process
|
|
61
|
+
// deployments behave exactly as they did. The body is synchronous, so two
|
|
62
|
+
// concurrent claims of the same token in one process cannot both win, which a
|
|
63
|
+
// separate has()-then-set() would allow.
|
|
64
|
+
const memoryStore = {
|
|
65
|
+
claim: (token, expiresAtMs) => {
|
|
66
|
+
const seen = used.get(token);
|
|
67
|
+
if (seen !== undefined && seen >= Date.now()) return false; // a still-valid record => already spent
|
|
68
|
+
used.set(token, expiresAtMs);
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const replay = shared || memoryStore;
|
|
73
|
+
const sweep = shared ? null : setInterval(() => {
|
|
31
74
|
const now = Date.now();
|
|
32
75
|
for (const [t, exp] of used) if (exp < now) used.delete(t);
|
|
33
76
|
}, 60_000);
|
|
34
|
-
sweep
|
|
77
|
+
sweep?.unref?.();
|
|
35
78
|
|
|
36
79
|
const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
37
80
|
|
|
@@ -54,7 +97,37 @@ export function createPow({
|
|
|
54
97
|
};
|
|
55
98
|
}
|
|
56
99
|
|
|
57
|
-
|
|
100
|
+
// Claim single-use, translating the store's answer into a verify() result.
|
|
101
|
+
//
|
|
102
|
+
// FAIL CLOSED: a store that throws or rejects means we cannot know whether this
|
|
103
|
+
// token was already spent, so we refuse the request. Same posture as edge.js,
|
|
104
|
+
// where a claim-store exception used to propagate out of verify instead of
|
|
105
|
+
// becoming a denial. An outage on the replay store costs the operator a few
|
|
106
|
+
// refused crawls; the alternative costs them the paywall.
|
|
107
|
+
function claimSingleUse(token, expiresAtMs) {
|
|
108
|
+
let claimed;
|
|
109
|
+
try { claimed = replay.claim(token, expiresAtMs); }
|
|
110
|
+
catch { return { ok: false, reason: "replay store unavailable" }; }
|
|
111
|
+
if (claimed && typeof claimed.then === "function") {
|
|
112
|
+
return claimed.then(
|
|
113
|
+
(granted) => (granted === true ? { ok: true } : { ok: false, reason: "already used" }),
|
|
114
|
+
() => ({ ok: false, reason: "replay store unavailable" }),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return claimed === true ? { ok: true } : { ok: false, reason: "already used" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Verify a "<token>:<nonce>" solution for a given resource.
|
|
121
|
+
*
|
|
122
|
+
* SYNC-OR-ASYNC BY DESIGN. Returns a plain result object whenever the replay
|
|
123
|
+
* store answers synchronously (the default in-process store always does), and
|
|
124
|
+
* a Promise of that object when a shared store answers with one. It is NOT
|
|
125
|
+
* unconditionally async because verify() has always been synchronous here:
|
|
126
|
+
* every existing caller reads `.ok` off the return value, and `.ok` on a
|
|
127
|
+
* Promise is `undefined`, so a blanket change would deny every honest solve
|
|
128
|
+
* while ALSO making the replay check a no-op for anyone who ignored the
|
|
129
|
+
* promise. Callers that may be configured with an async store must handle a
|
|
130
|
+
* thenable (index.js does; `await` works for both shapes). */
|
|
58
131
|
function verify(headerValue, resource) {
|
|
59
132
|
if (!headerValue || typeof headerValue !== "string") return { ok: false, reason: "missing solution" };
|
|
60
133
|
const cut = headerValue.lastIndexOf(":");
|
|
@@ -72,12 +145,21 @@ export function createPow({
|
|
|
72
145
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "bad signature" };
|
|
73
146
|
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
74
147
|
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
75
|
-
|
|
148
|
+
// Defence in depth: the difficulty rides INSIDE the signed token, so a
|
|
149
|
+
// forged or downgraded token could claim difficulty 0 and pass with no work
|
|
150
|
+
// at all. Hold every solution to the configured difficulty as well, so a
|
|
151
|
+
// leaked or placeholder secret still costs a real solve instead of handing
|
|
152
|
+
// over free passage.
|
|
153
|
+
const claimed = Number(diffStr);
|
|
154
|
+
if (!Number.isFinite(claimed) || claimed < difficulty) return { ok: false, reason: "difficulty below policy" };
|
|
76
155
|
const hash = createHash("sha256").update(`${chal}:${nonce}`).digest();
|
|
77
|
-
if (leadingZeroBits(hash) <
|
|
78
|
-
|
|
79
|
-
|
|
156
|
+
if (leadingZeroBits(hash) < claimed) return { ok: false, reason: "insufficient work" };
|
|
157
|
+
// Claim LAST, once the solution is known good: a forged, expired or
|
|
158
|
+
// under-worked attempt must never consume the token an honest solver is
|
|
159
|
+
// still holding, and with a shared store this is the only step that can
|
|
160
|
+
// cost a round trip.
|
|
161
|
+
return claimSingleUse(token, Number(expStr));
|
|
80
162
|
}
|
|
81
163
|
|
|
82
|
-
return { challenge, verify, difficulty };
|
|
164
|
+
return { challenge, verify, difficulty, replayStore: replay };
|
|
83
165
|
}
|
package/replay.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Shared proof-of-work replay stores for the Node gate.
|
|
2
|
+
//
|
|
3
|
+
// The gate's default single-use record lives in one process's memory. That is
|
|
4
|
+
// correct for a single-process deploy and a hole in every other shape: with a
|
|
5
|
+
// stable TOLLBOOTH_SECRET (which multi-worker deploys need, so tokens verify at
|
|
6
|
+
// all) one solved token is redeemable once per process inside its TTL, and again
|
|
7
|
+
// after each worker recycle. These factories give the `replayStore` option a
|
|
8
|
+
// backend every process shares.
|
|
9
|
+
//
|
|
10
|
+
// The contract is the one edge.js already uses, so a store written for the edge
|
|
11
|
+
// gate works here unchanged:
|
|
12
|
+
//
|
|
13
|
+
// { claim(token, expiresAtMs) => boolean | Promise<boolean> }
|
|
14
|
+
//
|
|
15
|
+
// `claim` must be ATOMIC: true only the very first time it sees a token, false
|
|
16
|
+
// for every later call. A get-then-put pair that is not atomic lets two
|
|
17
|
+
// concurrent redemptions of the same token both observe "unseen" and both pass.
|
|
18
|
+
// A store that throws makes the gate REFUSE the request (pow.js fails closed),
|
|
19
|
+
// so a claim implementation should throw rather than guess when its backend is
|
|
20
|
+
// unreachable.
|
|
21
|
+
//
|
|
22
|
+
// No new runtime dependency: every factory takes an ALREADY-OPEN client the
|
|
23
|
+
// operator constructed, so the tollbooth never pulls in a driver of its own.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SQLite-backed claim, for the common case of several Node workers on one host
|
|
27
|
+
* (`cluster`, pm2, a container with N processes).
|
|
28
|
+
*
|
|
29
|
+
* Pass an open better-sqlite3 `Database`, or a node:sqlite `DatabaseSync` (Node
|
|
30
|
+
* 22.5+). Both expose the same `exec()` / `prepare().run()` surface this uses.
|
|
31
|
+
*
|
|
32
|
+
* import Database from "better-sqlite3";
|
|
33
|
+
* const db = new Database("/var/lib/tollbooth/replay.db");
|
|
34
|
+
* db.pragma("journal_mode = WAL"); // concurrent writers
|
|
35
|
+
* db.pragma("busy_timeout = 5000"); // wait for the write lock, do not throw
|
|
36
|
+
* createTollbooth({ replayStore: sqliteReplayStore(db) });
|
|
37
|
+
*
|
|
38
|
+
* Set busy_timeout. A claim that hits SQLITE_BUSY throws, pow.js fails closed on
|
|
39
|
+
* a throw, and the refusal lands on an honest crawler that did the work. Waiting
|
|
40
|
+
* milliseconds for the write lock is strictly better than that.
|
|
41
|
+
*
|
|
42
|
+
* Atomicity comes from the PRIMARY KEY plus INSERT OR IGNORE: SQLite serializes
|
|
43
|
+
* writers on the database file, so exactly one process observes `changes === 1`
|
|
44
|
+
* for a given token. The scope of that guarantee is the FILE, which means this
|
|
45
|
+
* covers many processes sharing a disk and does NOT cover instances on different
|
|
46
|
+
* hosts (a network filesystem does not make SQLite locking reliable). Use Redis
|
|
47
|
+
* or Postgres across hosts.
|
|
48
|
+
*/
|
|
49
|
+
export function sqliteReplayStore(db, { table = "tollbooth_pow_replay", pruneEveryMs = 60_000 } = {}) {
|
|
50
|
+
if (!db || typeof db.prepare !== "function" || typeof db.exec !== "function") {
|
|
51
|
+
throw new TypeError("sqliteReplayStore(db): pass an open better-sqlite3 Database or node:sqlite DatabaseSync");
|
|
52
|
+
}
|
|
53
|
+
// Identifier, not a bindable value, so it is whitelisted rather than escaped.
|
|
54
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new TypeError("sqliteReplayStore: table must be a plain identifier");
|
|
55
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${table} (token TEXT PRIMARY KEY, expires_ms INTEGER NOT NULL)`);
|
|
56
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO ${table} (token, expires_ms) VALUES (?, ?)`);
|
|
57
|
+
const prune = db.prepare(`DELETE FROM ${table} WHERE expires_ms < ?`);
|
|
58
|
+
let prunedAt = 0;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
claim: (token, expiresAtMs) => {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
// Bounded growth without a background timer: the table only ever needs to
|
|
64
|
+
// hold unexpired tokens, and an expired row can be dropped because pow.js
|
|
65
|
+
// rejects an expired token before it ever claims. Time-gated so a busy
|
|
66
|
+
// gate does not run a DELETE on every request.
|
|
67
|
+
if (now - prunedAt > pruneEveryMs) { prunedAt = now; prune.run(now); }
|
|
68
|
+
const res = insert.run(String(token), Math.floor(Number(expiresAtMs) || now));
|
|
69
|
+
return Number(res.changes) === 1;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Redis-backed claim, for multiple instances across hosts (and the shape most
|
|
76
|
+
* serverless Node runtimes can reach).
|
|
77
|
+
*
|
|
78
|
+
* Pass an open client. Both major clients are supported because their SET
|
|
79
|
+
* signatures differ:
|
|
80
|
+
*
|
|
81
|
+
* node-redis v4+: createClient(...) -> set(k, v, { NX: true, PX: ttl })
|
|
82
|
+
* ioredis: new Redis(...) -> set(k, v, "PX", ttl, "NX")
|
|
83
|
+
*
|
|
84
|
+
* createTollbooth({ replayStore: redisReplayStore(redis, { prefix: "tb:pow:" }) });
|
|
85
|
+
*
|
|
86
|
+
* SET NX is atomic server-side across every client, and the PX expiry is the
|
|
87
|
+
* token's own remaining lifetime, so the keyspace self-cleans. `claim` returns a
|
|
88
|
+
* PROMISE, which is why pow.js verify() has to tolerate a thenable.
|
|
89
|
+
*
|
|
90
|
+
* Errors are deliberately NOT caught here: pow.js turns a throw into a refusal.
|
|
91
|
+
* Swallowing a Redis outage and returning true would silently restore the
|
|
92
|
+
* per-process hole this store exists to close.
|
|
93
|
+
*/
|
|
94
|
+
export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
|
|
95
|
+
if (!client || typeof client.set !== "function") {
|
|
96
|
+
throw new TypeError("redisReplayStore(client): pass an open node-redis or ioredis client");
|
|
97
|
+
}
|
|
98
|
+
// defineCommand is ioredis-only, and ioredis is the client that takes the
|
|
99
|
+
// positional SET form. Detect rather than send one shape and hope: the failure
|
|
100
|
+
// mode of guessing wrong is not an exception but a SET without the NX flag,
|
|
101
|
+
// which overwrites the record and grants every replay.
|
|
102
|
+
const isIoredis = typeof client.defineCommand === "function";
|
|
103
|
+
return {
|
|
104
|
+
claim: async (token, expiresAtMs) => {
|
|
105
|
+
const key = prefix + token;
|
|
106
|
+
// Floor of 1ms: Redis rejects a non-positive PX. pow.js has already
|
|
107
|
+
// refused anything actually expired, so a tiny window here just means the
|
|
108
|
+
// key is evicted almost immediately.
|
|
109
|
+
const px = Math.max(1, Math.floor(Number(expiresAtMs) - Date.now()));
|
|
110
|
+
const res = isIoredis
|
|
111
|
+
? await client.set(key, "1", "PX", px, "NX")
|
|
112
|
+
: await client.set(key, "1", { NX: true, PX: px });
|
|
113
|
+
// node-redis resolves null when NX declined; ioredis resolves null too,
|
|
114
|
+
// and "OK" on a win. Anything that is not a win is treated as already spent.
|
|
115
|
+
return res === "OK" || res === true;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
package/worker.js
CHANGED
|
@@ -28,9 +28,10 @@ function constEq(a, b) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const kvStore = (kv) => ({
|
|
31
|
-
// Best-effort
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Best-effort claim. KV has no native compare-and-set, so this is get-then-put
|
|
32
|
+
// (eventually consistent) — concurrent dupes across isolates/locations can
|
|
33
|
+
// both observe "unseen" and both pass. For STRICT single-use use the Durable
|
|
34
|
+
// Object store below. Returns true only the first time a token is seen.
|
|
34
35
|
claim: async (k, expMs) => {
|
|
35
36
|
if ((await kv.get(k)) != null) return false;
|
|
36
37
|
await kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) });
|
|
@@ -38,15 +39,67 @@ const kvStore = (kv) => ({
|
|
|
38
39
|
},
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
// F05: strict single-use replay claim via a Durable Object. A DO instance is
|
|
43
|
+
// single-threaded — every request addressed to one DO id is serialized — so the
|
|
44
|
+
// get-then-put INSIDE it is ATOMIC across all isolates and locations (KV is
|
|
45
|
+
// eventually consistent and has no compare-and-set). Each token is routed to its
|
|
46
|
+
// own DO id, so claims only ever contend on the exact token they concern.
|
|
47
|
+
export class TollboothReplay {
|
|
48
|
+
constructor(state) { this.state = state; }
|
|
49
|
+
async fetch(request) {
|
|
50
|
+
let expMs = 0;
|
|
51
|
+
try { expMs = Number((await request.json()).expMs) || 0; } catch { /* */ }
|
|
52
|
+
const already = await this.state.storage.get("claimed");
|
|
53
|
+
if (already != null) return Response.json({ granted: false });
|
|
54
|
+
await this.state.storage.put("claimed", 1);
|
|
55
|
+
// Auto-expire the DO storage at the token's expiry — the token is worthless
|
|
56
|
+
// after that, so the claim record can be dropped and storage stays bounded.
|
|
57
|
+
if (expMs > Date.now()) { try { await this.state.storage.setAlarm(expMs); } catch { /* */ } }
|
|
58
|
+
return Response.json({ granted: true });
|
|
59
|
+
}
|
|
60
|
+
async alarm() { try { await this.state.storage.deleteAll(); } catch { /* */ } }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const durableObjectStore = (namespace) => ({
|
|
64
|
+
claim: async (k, expMs) => {
|
|
65
|
+
const stub = namespace.get(namespace.idFromName(k));
|
|
66
|
+
const resp = await stub.fetch("https://tollbooth-replay/claim", { method: "POST", body: JSON.stringify({ expMs }) });
|
|
67
|
+
const { granted } = await resp.json();
|
|
68
|
+
return granted === true;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
41
72
|
export default {
|
|
42
73
|
async fetch(request, env, ctx) {
|
|
43
74
|
if (!env.TOLLBOOTH_SECRET) {
|
|
44
75
|
return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
|
|
45
76
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
// F05: pick the replay store, strongest first. A Durable Object
|
|
78
|
+
// (TOLLBOOTH_REPLAY) gives ATOMIC, strict single-use across isolates and
|
|
79
|
+
// locations. KV is a best-effort fallback (eventually consistent; concurrent
|
|
80
|
+
// dupes can both pass). With neither, protection is per-isolate only.
|
|
81
|
+
const enforcing = env.TOLLBOOTH_OBSERVE !== "true";
|
|
82
|
+
// FR4-04: FAIL CLOSED in enforcing mode without an ATOMIC replay store. A
|
|
83
|
+
// Durable Object (TOLLBOOTH_REPLAY) gives strict single-use across isolates
|
|
84
|
+
// and locations. KV is eventually consistent (get-then-put, NOT atomic), so
|
|
85
|
+
// concurrent duplicate PoW solutions can both pass — a warning does not
|
|
86
|
+
// protect a production deployment. Refuse to enforce on a non-atomic (or
|
|
87
|
+
// absent) store unless the operator sets an explicit, loud insecure override.
|
|
88
|
+
const allowNonAtomic = env.TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY === "true";
|
|
89
|
+
if (enforcing && !env.TOLLBOOTH_REPLAY && !allowNonAtomic) {
|
|
90
|
+
return new Response(
|
|
91
|
+
"Tollbooth misconfigured: enforcing mode requires an ATOMIC replay store. Bind a Durable Object as TOLLBOOTH_REPLAY (see deploy/cloudflare/wrangler.toml), run in observe mode (TOLLBOOTH_OBSERVE=true), or accept non-atomic replay explicitly with TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY=true.",
|
|
92
|
+
{ status: 500 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
let replayStore;
|
|
96
|
+
if (env.TOLLBOOTH_REPLAY) {
|
|
97
|
+
replayStore = durableObjectStore(env.TOLLBOOTH_REPLAY);
|
|
98
|
+
} else if (env.TOLLBOOTH_KV) {
|
|
99
|
+
replayStore = kvStore(env.TOLLBOOTH_KV);
|
|
100
|
+
if (enforcing) console.warn("agent402-tollbooth: ENFORCING on eventually-consistent KV via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override (get-then-put is NOT atomic — concurrent duplicate solutions across isolates/locations can both pass). Bind a Durable Object as TOLLBOOTH_REPLAY for strict single-use.");
|
|
101
|
+
} else if (enforcing) {
|
|
102
|
+
console.warn("agent402-tollbooth: ENFORCING with NO replay store via explicit TOLLBOOTH_ALLOW_NON_ATOMIC_REPLAY override — proof-of-work replay protection is per-isolate only. Bind a Durable Object for production.");
|
|
50
103
|
}
|
|
51
104
|
// Durable stats live in KV if a namespace is bound. Without it, the dashboard
|
|
52
105
|
// is per-isolate (dies on cold start) — fine for dev, useless for prod.
|
|
@@ -60,7 +113,7 @@ export default {
|
|
|
60
113
|
network: env.TOLLBOOTH_NETWORK || "base",
|
|
61
114
|
asset: env.TOLLBOOTH_ASSET || "USDC",
|
|
62
115
|
powDifficulty: env.TOLLBOOTH_POW_BITS ? Number(env.TOLLBOOTH_POW_BITS) : undefined,
|
|
63
|
-
store:
|
|
116
|
+
store: replayStore,
|
|
64
117
|
observe: env.TOLLBOOTH_OBSERVE === "true",
|
|
65
118
|
statsSink,
|
|
66
119
|
});
|