agent402-tollbooth 0.1.3 → 0.2.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 CHANGED
@@ -92,27 +92,41 @@ The same gate is also built on the Web Crypto + Fetch APIs (`edge.js`), so it ru
92
92
  anywhere — no Node required. The gate returns a `402 Response` when the client
93
93
  must pay, or `null` to let it through.
94
94
 
95
- **Cloudflare Workers** (poetic: the open pay-per-crawl, on the incumbent's own platform). `wrangler.toml`:
95
+ **Ready-to-deploy templates** (copy a folder, don't assemble from docs):
96
+
97
+ - **Cloudflare Workers** → [`deploy/cloudflare/`](deploy/cloudflare/) — a ready
98
+ `wrangler.toml` + a 3-step deploy guide (the open pay-per-crawl, on the
99
+ incumbent's own platform).
100
+ - **Next.js / Vercel** → [`deploy/nextjs/`](deploy/nextjs/) — a drop-in
101
+ `middleware.js` + a 3-step deploy guide.
102
+ - **Docker** → [`deploy/docker/`](deploy/docker/) — a `Dockerfile` +
103
+ `docker-compose.yml` to run the reverse proxy in front of any site with
104
+ `docker compose up -d` (includes the live `/__tollbooth` dashboard).
105
+
106
+ The short version of each:
96
107
 
97
108
  ```toml
109
+ # wrangler.toml (full template: deploy/cloudflare/wrangler.toml)
98
110
  name = "tollbooth"
99
111
  main = "node_modules/agent402-tollbooth/worker.js"
112
+ compatibility_date = "2026-01-01"
100
113
  [vars]
101
114
  TOLLBOOTH_UPSTREAM = "https://your-origin.example.com"
102
115
  TOLLBOOTH_PAYTO = "0xYourWallet" # optional: advertise a USDC x402 quote
103
- # wrangler secret put TOLLBOOTH_SECRET
116
+ # npx wrangler secret put TOLLBOOTH_SECRET
104
117
  # optional single-use replay store: [[kv_namespaces]] binding = "TOLLBOOTH_KV"
105
118
  ```
106
119
 
107
- **Next.js middleware** (`middleware.ts`):
108
-
109
- ```ts
120
+ ```js
121
+ // middleware.js (full template: deploy/nextjs/middleware.js)
122
+ import { NextResponse } from "next/server";
110
123
  import { createEdgeTollbooth } from "agent402-tollbooth/edge";
111
- const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET! });
124
+ const gate = createEdgeTollbooth({ secret: process.env.TOLLBOOTH_SECRET });
112
125
 
113
- export async function middleware(req: Request) {
126
+ export async function middleware(req) {
114
127
  return (await gate(req)) ?? NextResponse.next();
115
128
  }
129
+ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
116
130
  ```
117
131
 
118
132
  **Any Fetch-API runtime** (Deno, Bun, custom): `const gate = createEdgeTollbooth({ secret }); const blocked = await gate(request); return blocked ?? fetch(request);`
@@ -152,23 +166,46 @@ app.use(createTollbooth({
152
166
  | `network` | `"base"` | x402 network |
153
167
  | `pow` | `true` | Enable the free proof-of-work rail |
154
168
  | `powDifficulty` | `18` | PoW difficulty in leading zero bits (~0.1–0.5s of CPU) |
155
- | `botUserAgents` | `AI_BOTS` | User-agents to charge (AI/LLM crawlers; search indexers excluded) |
156
- | `charge(req)` | UA match | Custom predicate for "should this client pay?" |
157
- | `free(req)` | – | Custom force-allow predicate (wins over `charge`) |
169
+ | `mode` | `"bots"` | Who pays: `"bots"` (AI-crawler UAs) · `"all"` (everyone but `free()`) · `"strict"` (anything that isn't a real-browser request) |
170
+ | `adaptive` | `false` | Raise PoW difficulty as charged-request load climbs (anti-abuse under traffic spikes) |
171
+ | `maxDifficulty` | `base+6` | Ceiling for adaptive difficulty |
172
+ | `adaptivePerBit` | `300` | +1 difficulty bit per N charged requests/min |
173
+ | `botUserAgents` | `AI_BOTS` | User-agents to charge in `"bots"` mode |
174
+ | `charge(req)` | mode | Custom "should this client pay?" predicate (wins over `mode`) |
175
+ | `free(req)` | – | Custom force-allow predicate (wins over everything) |
158
176
  | `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
159
177
  | `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
160
178
 
161
179
  Environment variables: `TOLLBOOTH_UPSTREAM`, `TOLLBOOTH_PAYTO`, `TOLLBOOTH_PRICE`,
162
- `TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_SECRET`, `PORT`.
180
+ `TOLLBOOTH_NETWORK`, `TOLLBOOTH_POW_BITS`, `TOLLBOOTH_MODE`, `TOLLBOOTH_ADAPTIVE`,
181
+ `TOLLBOOTH_MAX_POW_BITS`, `TOLLBOOTH_ADAPTIVE_PER_BIT`, `TOLLBOOTH_SECRET`, `PORT`.
163
182
 
164
183
  ## How it decides who pays
165
184
 
166
- By default it charges requests whose `User-Agent` matches a known **AI/LLM
167
- crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider, Google-Extended,
168
- Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are intentionally
169
- **not** charged so your SEO indexing stays free. Override with `botUserAgents`,
170
- or take full control with a `charge(req)` predicate (e.g. charge anything
171
- without a browser-like `Accept` header, or charge everyone on `/api/*`).
185
+ By default (`mode: "bots"`) it charges requests whose `User-Agent` matches a known
186
+ **AI/LLM crawler** (GPTBot, ClaudeBot, CCBot, PerplexityBot, Bytespider,
187
+ Google-Extended, Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are
188
+ intentionally **not** charged so your SEO indexing stays free.
189
+
190
+ **Don't want to play whack-a-mole with bot detection?** That's the point of the
191
+ other modes — you stop trying to *identify* bots and instead make access *cost
192
+ something*:
193
+ - `mode: "all"` charges every client (except a `free()` match). A "more
194
+ sophisticated" bot gains nothing by disguising itself — everyone pays or solves
195
+ a proof-of-work.
196
+ - `mode: "strict"` charges anything that isn't a real-browser request (browser-like
197
+ UA **and** an HTML `Accept`), letting genuine human page-loads through free.
198
+ - `adaptive: true` makes proof-of-work **harder as load climbs**, so a high-volume
199
+ scraper pays escalating CPU per request regardless of how it looks — detection is
200
+ cat-and-mouse, economics isn't.
201
+
202
+ ## Analytics
203
+
204
+ The middleware keeps aggregate counters (no per-request data): `gate.stats()`
205
+ returns `{ requests, freeAllowed, charged, powSolved, x402Paid, difficultyNow }`.
206
+ The reverse-proxy CLI exposes them as JSON at **`/__tollbooth/stats`** and as a
207
+ live **dashboard at `/__tollbooth`** — requests, how many were charged,
208
+ proof-of-work solves, USDC collected, and what share of your traffic is bots.
172
209
 
173
210
  ## Production checklist (read this)
174
211
 
@@ -183,8 +220,9 @@ without a browser-like `Accept` header, or charge everyone on `/api/*`).
183
220
  redirect it elsewhere) and **strips client-forged trust/forwarding headers**
184
221
  (`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
185
222
  - **UA matching is the default, not a security boundary** — a bot can forge a
186
- human UA to get the *same free access a human gets* (it gains nothing more). If
187
- you need to charge everyone, pass `charge: () => true`.
223
+ human UA to get the *same free access a human gets* (it gains nothing more). To
224
+ stop relying on detection entirely, use `mode: "all"` / `mode: "strict"`, and
225
+ turn on `adaptive` so high-volume abuse pays escalating proof-of-work.
188
226
 
189
227
  ## Notes
190
228
 
package/dashboard.js ADDED
@@ -0,0 +1,48 @@
1
+ // A tiny self-contained operator dashboard for the tollbooth, served at
2
+ // /__tollbooth by the reverse-proxy CLI. It polls /__tollbooth/stats and shows
3
+ // how much of your traffic is bots and what the gate is collecting — the visual
4
+ // answer to "I had to upgrade my plan to serve bots I don't monetize."
5
+ // Pure function (no deps) so it's trivially testable.
6
+ export function dashboardHtml() {
7
+ return `<!doctype html>
8
+ <html lang="en"><head>
9
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <meta name="robots" content="noindex">
11
+ <title>agent402-tollbooth — live stats</title>
12
+ <style>
13
+ :root{--bg:#0b0e14;--card:#131826;--text:#e6e9f0;--muted:#8b93a7;--accent:#4ade80;--line:#1e2638;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
14
+ *{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,sans-serif}
15
+ .wrap{max-width:880px;margin:0 auto;padding:40px 20px}
16
+ h1{font-size:1.4rem;display:flex;align-items:center;gap:10px}
17
+ .badge{font:700 14px/1 var(--mono);color:var(--accent);background:#000;border:1px solid #1f4a1d;border-radius:8px;padding:8px 10px}
18
+ .sub{color:var(--muted);margin:6px 0 24px;font-size:.9rem}
19
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px}
20
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px}
21
+ .k{color:var(--muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
22
+ .v{font:800 1.8rem/1.1 var(--mono);margin-top:8px}
23
+ .v.accent{color:var(--accent)}
24
+ footer{color:var(--muted);font-size:.8rem;margin-top:28px}
25
+ a{color:var(--accent)}
26
+ </style></head>
27
+ <body><div class="wrap">
28
+ <h1><span class="badge">402</span> agent402-tollbooth</h1>
29
+ <div class="sub">Live pay-per-crawl stats · refreshes every 5s · <span id="since"></span></div>
30
+ <div class="grid" id="grid"></div>
31
+ <footer>Bots are <b id="botpct">—</b> of requests. Aggregate counts only (no per-request data). Raw JSON: <a href="/__tollbooth/stats">/__tollbooth/stats</a>.</footer>
32
+ </div>
33
+ <script>
34
+ const cards=[["requests","Requests",false],["freeAllowed","Humans (free)",false],["charged","Charged (402)","accent"],["powSolved","Proof-of-work paid","accent"],["x402Paid","USDC paid (x402)","accent"],["difficultyNow","PoW difficulty",false]];
35
+ async function tick(){
36
+ try{
37
+ const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
38
+ document.getElementById("grid").innerHTML=cards.map(([k,label,acc])=>
39
+ '<div class="card"><div class="k">'+label+'</div><div class="v '+(acc||"")+'">'+(s[k]??0)+'</div></div>').join("");
40
+ const pct=s.requests?Math.round((s.charged/s.requests)*100):0;
41
+ document.getElementById("botpct").textContent=pct+"%";
42
+ if(s.since)document.getElementById("since").textContent="since "+new Date(s.since).toLocaleString();
43
+ }catch(e){/* keep last values */}
44
+ }
45
+ tick();setInterval(tick,5000);
46
+ </script>
47
+ </body></html>`;
48
+ }
package/edge.js CHANGED
@@ -37,22 +37,28 @@ function constEq(a, b) {
37
37
  /**
38
38
  * Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
39
39
  * random per-process secret across invocations). `store` is an optional
40
- * single-use replay backend ({ has(token), add(token, expMs) }, both async) —
41
- * e.g. a Cloudflare KV wrapper; defaults to in-isolate memory.
40
+ * single-use replay backend: { claim(token, expMs) => bool|Promise<bool> } that
41
+ * atomically returns true only the first time a token is seen — e.g. a Cloudflare
42
+ * KV wrapper; defaults to in-isolate memory.
42
43
  */
43
44
  export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
44
45
  if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
45
46
  const mem = new Map();
46
47
  const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
47
48
  const used = store || {
48
- has: async (k) => { const e = mem.get(k); if (e && e < Date.now()) { mem.delete(k); return false; } return mem.has(k); },
49
- add: async (k, exp) => {
49
+ // Atomic single-use claim. The body is synchronous (no await), so two
50
+ // concurrent claims of the same token cannot both win within an isolate —
51
+ // closing the TOCTOU that a separate has()+add() would open.
52
+ claim: (k, exp) => {
53
+ const e = mem.get(k);
54
+ if (e && e >= Date.now()) return false; // a still-valid entry => already used
50
55
  if (mem.size >= MAX) {
51
56
  const now = Date.now();
52
57
  for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
53
58
  if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
54
59
  }
55
60
  mem.set(k, exp);
61
+ return true;
56
62
  },
57
63
  };
58
64
 
@@ -88,10 +94,11 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
88
94
  if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
89
95
  if (res !== resource) return { ok: false, reason: "wrong resource" };
90
96
  if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
91
- if (await used.has(token)) return { ok: false, reason: "already used" };
92
97
  const h = await sha256(`${chal}:${nonce}`);
93
98
  if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
94
- await used.add(token, Number(expStr));
99
+ // Atomically claim single-use only AFTER the work is validated, so an invalid
100
+ // attempt never consumes the token and concurrent dupes can't both pass.
101
+ if (!(await used.claim(token, Number(expStr)))) return { ok: false, reason: "already used" };
95
102
  return { ok: true };
96
103
  }
97
104
 
@@ -114,6 +121,10 @@ export function createEdgeTollbooth(config = {}) {
114
121
  secret,
115
122
  store,
116
123
  botUserAgents = AI_BOTS,
124
+ // "bots" (default, charge AI crawler UAs) | "all" (charge all but free()) |
125
+ // "strict" (charge anything that isn't a real-browser request). An explicit
126
+ // charge()/free() still wins. Default preserves the original behavior.
127
+ mode = "bots",
117
128
  charge,
118
129
  free,
119
130
  resourceBaseUrl = "",
@@ -123,12 +134,19 @@ export function createEdgeTollbooth(config = {}) {
123
134
  const isBot = makeBotMatcher(botUserAgents);
124
135
  const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
125
136
 
137
+ const looksHuman = (request) => {
138
+ const ua = request.headers.get("user-agent") || "";
139
+ const accept = request.headers.get("accept") || "";
140
+ return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
141
+ };
126
142
  const shouldCharge = (request) => {
127
143
  try {
128
144
  if (typeof free === "function" && free(request)) return false;
129
145
  if (typeof charge === "function") return Boolean(charge(request));
130
146
  } catch { return true; /* fail closed: charge on predicate error */ }
131
- return isBot(request.headers.get("user-agent") || "");
147
+ if (mode === "all") return true;
148
+ if (mode === "strict") return !looksHuman(request);
149
+ return isBot(request.headers.get("user-agent") || ""); // "bots" (default)
132
150
  };
133
151
 
134
152
  return async function gate(request) {
package/index.js CHANGED
@@ -55,22 +55,58 @@ export function createTollbooth(config = {}) {
55
55
  powDifficulty,
56
56
  powSecret,
57
57
  botUserAgents = AI_BOTS,
58
+ // Who pays. Default "bots" = the original behavior (charge AI crawler UAs).
59
+ // "all" — charge every client except a `free()` match (UA detection is
60
+ // not a security boundary; this stops relying on it).
61
+ // "strict" — charge anything that isn't a real-browser request (browser-like
62
+ // UA + an HTML Accept). Raises the bar on naive scrapers.
63
+ // An explicit charge()/free() still wins over the mode.
64
+ mode = process.env.TOLLBOOTH_MODE || "bots",
58
65
  charge,
59
66
  free,
60
67
  verifyX402,
61
68
  resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
69
+ // Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
70
+ // high-volume abuse pays escalating CPU regardless of how it disguises itself.
71
+ // Off by default — behavior is unchanged unless explicitly enabled.
72
+ adaptive = config.adaptive ?? (process.env.TOLLBOOTH_ADAPTIVE === "true"),
73
+ adaptivePerBit = Number(process.env.TOLLBOOTH_ADAPTIVE_PER_BIT) || 300, // +1 bit per N charged req/min
74
+ maxDifficulty,
62
75
  message = "This resource charges automated / AI clients per request. Humans browse free; bots pay in USDC via x402 or by solving a proof-of-work.",
63
76
  } = config;
64
77
 
65
78
  const isBot = makeBotMatcher(botUserAgents);
66
79
  const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
67
80
 
81
+ // Passive analytics — never affects request handling, just counts what happens.
82
+ const stats = { since: new Date().toISOString(), requests: 0, freeAllowed: 0, charged: 0, powSolved: 0, x402Paid: 0 };
83
+
84
+ const looksHuman = (req) => {
85
+ const ua = req.headers["user-agent"] || "";
86
+ const accept = req.headers["accept"] || "";
87
+ return /mozilla\/5\.0/i.test(ua) && /text\/html/i.test(accept);
88
+ };
68
89
  const shouldCharge = (req) => {
69
90
  try {
70
91
  if (typeof free === "function" && free(req)) return false;
71
92
  if (typeof charge === "function") return Boolean(charge(req));
72
93
  } catch { return true; /* fail closed: charge on predicate error */ }
73
- return isBot(req.headers["user-agent"] || "");
94
+ if (mode === "all") return true;
95
+ if (mode === "strict") return !looksHuman(req);
96
+ return isBot(req.headers["user-agent"] || ""); // "bots" (default)
97
+ };
98
+
99
+ // Sliding-window of recent charged requests → adaptive PoW difficulty.
100
+ const baseDifficulty = powEngine?.difficulty ?? (Number(process.env.TOLLBOOTH_POW_BITS) || 18);
101
+ const ceilDifficulty = Math.min(Number(maxDifficulty) || baseDifficulty + 6, 32);
102
+ const ADAPT_WINDOW_MS = 60_000;
103
+ let chargedWindow = [];
104
+ const difficultyNow = () => {
105
+ if (!adaptive) return baseDifficulty;
106
+ const cut = Date.now() - ADAPT_WINDOW_MS;
107
+ if (chargedWindow.length > 100_000) chargedWindow = chargedWindow.filter((t) => t > cut); // hard bound
108
+ else while (chargedWindow.length && chargedWindow[0] < cut) chargedWindow.shift();
109
+ return Math.min(baseDifficulty + Math.floor(chargedWindow.length / Math.max(1, adaptivePerBit)), ceilDifficulty);
74
110
  };
75
111
  const resourceOf = (req) => {
76
112
  // Canonicalize to path+search (matches the edge impl) so the PoW binding is
@@ -82,10 +118,13 @@ export function createTollbooth(config = {}) {
82
118
  };
83
119
 
84
120
  function tollbooth(req, res, next) {
85
- if (!shouldCharge(req)) return next();
121
+ stats.requests++;
122
+ if (!shouldCharge(req)) { stats.freeAllowed++; return next(); }
86
123
  const resource = resourceOf(req);
87
124
 
88
125
  const send402 = (extra = {}) => {
126
+ stats.charged++;
127
+ if (adaptive) chargedWindow.push(Date.now());
89
128
  const body = {
90
129
  error: "Payment Required",
91
130
  message,
@@ -94,7 +133,7 @@ export function createTollbooth(config = {}) {
94
133
  : [],
95
134
  ...extra,
96
135
  };
97
- if (powEngine) body.proofOfWork = powEngine.challenge(resource);
136
+ if (powEngine) body.proofOfWork = powEngine.challenge(resource, difficultyNow());
98
137
  res.status(402).json(body);
99
138
  };
100
139
 
@@ -102,7 +141,7 @@ export function createTollbooth(config = {}) {
102
141
  const powHeader = req.headers["x-pow-solution"];
103
142
  if (powEngine && powHeader) {
104
143
  const r = powEngine.verify(powHeader, resource);
105
- if (r.ok) { res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
144
+ if (r.ok) { stats.powSolved++; res.setHeader("X-Tollbooth-Paid", "pow"); return next(); }
106
145
  res.setHeader("X-Pow-Error", r.reason);
107
146
  }
108
147
 
@@ -114,7 +153,7 @@ export function createTollbooth(config = {}) {
114
153
  const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
115
154
  return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
116
155
  .then((ok) => {
117
- if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
156
+ if (ok) { stats.x402Paid++; res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
118
157
  send402();
119
158
  })
120
159
  .catch(() => { res.setHeader("X-Tollbooth-Error", "x402-verify-failed"); send402(); });
@@ -125,6 +164,9 @@ export function createTollbooth(config = {}) {
125
164
 
126
165
  tollbooth.shouldCharge = shouldCharge;
127
166
  tollbooth.pow = powEngine;
167
+ // Live counters for operators: how much traffic, how much was charged, and how
168
+ // it was settled. A point-in-time snapshot (never mutated by the caller).
169
+ tollbooth.stats = () => ({ ...stats, difficultyNow: difficultyNow() });
128
170
  return tollbooth;
129
171
  }
130
172
 
@@ -187,6 +229,11 @@ async function startCli() {
187
229
  const { default: express } = await import("express");
188
230
  const app = express();
189
231
  const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
232
+ // Operator analytics — aggregate counts only (no per-request data), mounted
233
+ // before the gate so they're always reachable and never themselves charged.
234
+ const { dashboardHtml } = await import("./dashboard.js");
235
+ app.get("/__tollbooth", (_req, res) => res.type("html").send(dashboardHtml()));
236
+ app.get("/__tollbooth/stats", (_req, res) => res.json(gate.stats()));
190
237
  app.use(gate);
191
238
  if (upstream) {
192
239
  app.use(createProxy(upstream));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy — no Cloudflare, no Stripe, no signup.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,6 +16,7 @@
16
16
  "worker.js",
17
17
  "bots.js",
18
18
  "pow.js",
19
+ "dashboard.js",
19
20
  "example.js",
20
21
  "demo.js",
21
22
  "README.md"
@@ -24,7 +25,7 @@
24
25
  "node": ">=20"
25
26
  },
26
27
  "scripts": {
27
- "test": "node test.js && node edge.test.js",
28
+ "test": "node test.js && node edge.test.js && node features.test.js",
28
29
  "demo": "node demo.js",
29
30
  "example": "node example.js"
30
31
  },
package/pow.js CHANGED
@@ -35,19 +35,22 @@ export function createPow({
35
35
 
36
36
  const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
37
37
 
38
- /** Mint a signed, resource-scoped challenge. */
39
- function challenge(resource) {
38
+ /** Mint a signed, resource-scoped challenge. `diff` lets the caller raise the
39
+ * difficulty for this one challenge (e.g. adaptive-under-load); verify reads
40
+ * the difficulty from the token, so any value is self-describing and safe. */
41
+ function challenge(resource, diff = difficulty) {
42
+ const d = Math.max(1, Math.min(Math.floor(Number(diff) || difficulty), 32));
40
43
  const chal = randomBytes(16).toString("hex");
41
44
  const exp = Date.now() + ttlMs;
42
- const payload = `${chal}.${exp}.${difficulty}.${resource}`;
45
+ const payload = `${chal}.${exp}.${d}.${resource}`;
43
46
  const token = `${payload}.${sign(payload)}`;
44
47
  return {
45
48
  algorithm: "sha256",
46
49
  challenge: chal,
47
- difficulty,
50
+ difficulty: d,
48
51
  expires: exp,
49
52
  token,
50
- rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${difficulty} leading zero bits, then resend the request with header X-Pow-Solution: ${token}:<nonce>`,
53
+ rule: `Find an integer nonce so sha256("${chal}:" + nonce) has >= ${d} leading zero bits, then resend the request with header X-Pow-Solution: ${token}:<nonce>`,
51
54
  };
52
55
  }
53
56
 
package/worker.js CHANGED
@@ -13,8 +13,14 @@
13
13
  import { createEdgeTollbooth } from "./edge.js";
14
14
 
15
15
  const kvStore = (kv) => ({
16
- has: async (k) => (await kv.get(k)) != null,
17
- add: async (k, expMs) => kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) }),
16
+ // Best-effort atomic claim. KV has no native compare-and-set, so this is
17
+ // get-then-put (eventually consistent); for strict single-use, back it with a
18
+ // Durable Object. Returns true only the first time a token is seen.
19
+ claim: async (k, expMs) => {
20
+ if ((await kv.get(k)) != null) return false;
21
+ await kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) });
22
+ return true;
23
+ },
18
24
  });
19
25
 
20
26
  export default {