agent402-tollbooth 0.10.5 → 0.10.7

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
@@ -271,8 +271,14 @@ settle-after-handler discipline as the x402 rail - then replays the response
271
271
  with a `Payment-Receipt` header and `X-Tollbooth-Paid: mpp-tempo`. A refused
272
272
  credential gets a 402 with fresh challenges and an RFC 9457 `problem` in the
273
273
  body. Challenges are HMAC-bound to your `TOLLBOOTH_SECRET`; each credential is
274
- single-use (share a `replayStore` across workers). No new dependency: the
275
- relay is spoken to over plain `fetch`.
274
+ single-use through the same `replayStore` that records proof-of-work solves
275
+ (keys prefixed `tempo:`). **Without a `replayStore` that record is per
276
+ process**: behind a load balancer, under `cluster`, or across instances, one
277
+ Tempo credential is accepted once per process until its challenge expires, and
278
+ the gate logs a warning at startup. Pass `sqliteReplayStore(db)` or
279
+ `redisReplayStore(client)` (or `TOLLBOOTH_REPLAY_SQLITE` for the CLI) in any
280
+ deploy with more than one process. No new dependency: the relay is spoken to
281
+ over plain `fetch`.
276
282
 
277
283
  **Chain-truth confirm (0.9.2):** a relay can report settlement failure for a
278
284
  payment that actually landed - measured live: a buyer whose packed signature
@@ -318,7 +324,7 @@ live incident transaction in the tests).
318
324
  | `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
319
325
  | `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
320
326
  | `statsSink` | in-memory | Durable stats backend. Built-ins: `memorySink`, `kvStatsSink(kv)`, `httpStatsSink(url)`. |
321
- | `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. |
327
+ | `replayStore` | **this process's memory** | Shared single-use record for solved proof-of-work tokens and Tempo credentials. Required for multi-worker / multi-instance / serverless. Built-ins: `sqliteReplayStore(db)`, `redisReplayStore(client)`. See below. |
322
328
 
323
329
  ### Environment variables
324
330
 
package/demo.js CHANGED
@@ -52,7 +52,7 @@ try {
52
52
  r = await visit(CRAWLER, { "x-pow-solution": `${q.proofOfWork.token}:${nonce}` });
53
53
  log(` → HTTP ${r.status} ${c.green}OK${c.reset} (paid via ${r.headers.get("x-tollbooth-paid")}) "${await r.text()}"\n`);
54
54
 
55
- log(`${c.bold}${c.green}✓ Pay-per-crawl, end to end${c.reset}: humans free, bots pay (USDC or compute). No Cloudflare, no Stripe, no signup.`);
55
+ log(`${c.bold}${c.green}✓ Pay-per-crawl, end to end${c.reset}: humans free, bots pay (USDC or compute), settled to your own wallet with no signup.`);
56
56
  } finally {
57
57
  server.close();
58
58
  }
package/edge.js CHANGED
@@ -98,8 +98,13 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
98
98
  if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
99
99
  if (res !== resource) return { ok: false, reason: "wrong resource" };
100
100
  if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
101
+ // Hold every solution to the configured difficulty too, not only the one the
102
+ // token claims (same rule as pow.js), so a token minted under a lower
103
+ // setting, or by another gate sharing the secret, buys no discount.
104
+ const claimedBits = Number(diffStr);
105
+ if (!Number.isFinite(claimedBits) || claimedBits < difficulty) return { ok: false, reason: "difficulty below policy" };
101
106
  const h = await sha256(`${chal}:${nonce}`);
102
- if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
107
+ if (leadingZeroBits(h) < claimedBits) return { ok: false, reason: "insufficient work" };
103
108
  // Atomically claim single-use only AFTER the work is validated, so an invalid
104
109
  // attempt never consumes the token and concurrent dupes can't both pass.
105
110
  // F05: FAIL CLOSED, if the claim store is unavailable, deny rather than let
package/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Put it in front of any site or API: human visitors pass through free, while
5
5
  // AI crawlers / agents must pay per request, either in USDC over the x402
6
6
  // protocol, or for free by solving a proof-of-work (no wallet, no signup, no
7
- // Stripe, no Cloudflare). Use it two ways:
7
+ // account). Use it two ways:
8
8
  //
9
9
  // 1. Express middleware: app.use(createTollbooth({ ... }))
10
10
  // 2. Reverse proxy (CLI): TOLLBOOTH_UPSTREAM=https://your-site.com npx agent402-tollbooth
@@ -17,6 +17,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import { Readable } from "node:stream";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import { createPow } from "./pow.js";
20
+ import { CLIENT_TRUST_HEADERS, HOP_BY_HOP_HEADERS } from "./strip-headers.js";
20
21
  import { makeBotMatcher, AI_BOTS } from "./bots.js";
21
22
  import { memorySink } from "./sinks.js";
22
23
  import { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, isMppCredential, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
@@ -94,14 +95,9 @@ export function x402VerifierFromExpress(paymentMiddleware, { timeoutMs } = {}) {
94
95
  });
95
96
  }
96
97
  // Headers a client must never be able to forge through the proxy: the gate's own
97
- // trust signals and forwarding/hop-by-hop headers.
98
- const STRIP_INBOUND = new Set([
99
- "host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
100
- "te", "trailer", "transfer-encoding", "upgrade", "content-length",
101
- "x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded",
102
- // client-IP / scheme trust headers an upstream might honour (spoofable by the bot)
103
- "x-real-ip", "cf-connecting-ip", "true-client-ip", "x-client-ip", "fastly-client-ip", "x-forwarded-proto", "x-forwarded-port", "via",
104
- ]);
98
+ // trust signals and forwarding/hop-by-hop headers (strip-headers.js, shared with
99
+ // the edge Worker).
100
+ const STRIP_INBOUND = new Set([...HOP_BY_HOP_HEADERS, ...CLIENT_TRUST_HEADERS]);
105
101
 
106
102
  /**
107
103
  * Create the tollbooth Express middleware.
@@ -231,6 +227,9 @@ export function createTollbooth(config = {}) {
231
227
  console.warn("[agent402-tollbooth] Tempo enabled with a per-process random secret - set TOLLBOOTH_SECRET (or mppSecret) for multi-worker deploys.");
232
228
  }
233
229
  const tempoRelayClient = tempoCfg ? tempoRelay(tempoCfg) : null;
230
+ if (tempoCfg && !(replayStore && typeof replayStore.claim === "function")) {
231
+ console.warn("[agent402-tollbooth] WARNING: Tempo rail enabled with NO shared replayStore - each Tempo credential is single-use PER PROCESS only. With more than one process or instance, one credential is accepted once per process until its challenge expires. Pass replayStore (sqliteReplayStore / redisReplayStore, or TOLLBOOTH_REPLAY_SQLITE for the CLI) for any multi-process deploy.");
232
+ }
234
233
  // Single-use record for tempo credentials (challenge id), sharing the
235
234
  // operator's replayStore when given (same atomic `claim` contract as PoW).
236
235
  const tempoSeen = new Map(); // id -> expiresAt (in-process fallback)
@@ -887,7 +886,7 @@ async function startCli() {
887
886
  if (upstream) {
888
887
  app.use(createProxy(upstream));
889
888
  } else {
890
- app.use((_req, res) => res.json({ ok: true, note: "Bare tollbooth gate (no TOLLBOOTH_UPSTREAM set). Clients that reach here paid or solved a proof-of-work." }));
889
+ app.use((_req, res) => res.json({ ok: true, note: "Bare tollbooth gate (no TOLLBOOTH_UPSTREAM set). This answer means the request passed the gate (free, paid or solved); it is not a verifier response." }));
891
890
  }
892
891
  const server = app.listen(port, () => {
893
892
  const paidLabel = x402mw ? `x402 + MPP (${process.env.TOLLBOOTH_ASSET || "USDC"}, settling via ${process.env.TOLLBOOTH_CDP_API_KEY_ID && process.env.TOLLBOOTH_CDP_API_KEY_SECRET ? "Coinbase CDP" : process.env.TOLLBOOTH_FACILITATOR_URL})` : (process.env.TOLLBOOTH_PAYTO ? `x402 quote only (${process.env.TOLLBOOTH_ASSET || "USDC"}, NOT settling - set TOLLBOOTH_FACILITATOR_URL)` : "");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.10.5",
4
- "description": "Agent402 tollbooth: open-source, self-hostable pay-per-crawl for x402 AND MPP (the sell side of Agentic Finance): charge AI bots per request (USDC via x402, or MPP - the Machine Payments Protocol - through the same settlement, or USDG on Robinhood Chain, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy that settles both wires from env alone - no Cloudflare, no Stripe, no signup.",
3
+ "version": "0.10.7",
4
+ "description": "Agent402 tollbooth: open-source, self-hostable pay-per-crawl for x402 AND MPP (the sell side of Agentic Finance): charge AI bots per request (USDC via x402, or MPP - the Machine Payments Protocol - through the same settlement, or USDG on Robinhood Chain, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy that settles both wires from env alone, on your own server with no account or signup.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "dashboard.js",
37
37
  "sinks.js",
38
38
  "replay.js",
39
+ "strip-headers.js",
39
40
  "example.js",
40
41
  "demo.js",
41
42
  "README.md"
@@ -0,0 +1,19 @@
1
+ // Inbound headers the tollbooth never forwards to the origin. Shared by the
2
+ // Node reverse proxy (index.js) and the edge Worker (worker.js) so both strip
3
+ // the same client-forgeable set. No node:* imports: the Worker loads this too.
4
+
5
+ // The gate's own trust signals, forwarding headers, and client-IP / scheme
6
+ // headers an origin might honor. A client can send any of these, so none of
7
+ // them is passed through.
8
+ export const CLIENT_TRUST_HEADERS = Object.freeze([
9
+ "x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded",
10
+ "x-real-ip", "cf-connecting-ip", "true-client-ip", "x-client-ip", "fastly-client-ip",
11
+ "x-forwarded-proto", "x-forwarded-port", "via",
12
+ ]);
13
+
14
+ // Hop-by-hop and framing headers the Node proxy rebuilds itself. The Worker's
15
+ // fetch manages these on its own, so the Worker does not strip them.
16
+ export const HOP_BY_HOP_HEADERS = Object.freeze([
17
+ "host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
18
+ "te", "trailer", "transfer-encoding", "upgrade", "content-length",
19
+ ]);
package/worker.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // # ↑ same TOLLBOOTH_KV is reused for durable stats aggregation across isolates.
16
16
  import { createEdgeTollbooth, kvStatsSink } from "./edge.js";
17
17
  import { dashboardHtml } from "./dashboard.js";
18
+ import { CLIENT_TRUST_HEADERS } from "./strip-headers.js";
18
19
 
19
20
  // Constant-time string compare, Cloudflare Workers don't ship node:crypto's
20
21
  // timingSafeEqual. Short-circuiting `===` on a secret token leaks length and
@@ -27,6 +28,9 @@ function constEq(a, b) {
27
28
  return r === 0;
28
29
  }
29
30
 
31
+ // Same rule as the CLI (index.js startCli): any value starting "change-me".
32
+ export const isPlaceholderSecret = (s) => /^change-me/i.test(String(s || ""));
33
+
30
34
  const kvStore = (kv) => ({
31
35
  // Best-effort claim. KV has no native compare-and-set, so this is get-then-put
32
36
  // (eventually consistent), concurrent dupes across isolates/locations can
@@ -74,6 +78,11 @@ export default {
74
78
  if (!env.TOLLBOOTH_SECRET) {
75
79
  return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
76
80
  }
81
+ // The deploy template's placeholder is public, so tokens signed with it can
82
+ // be minted by anyone. Refuse it, as the CLI does.
83
+ if (isPlaceholderSecret(env.TOLLBOOTH_SECRET)) {
84
+ return new Response("Tollbooth misconfigured: TOLLBOOTH_SECRET is the public placeholder from the deploy template. Generate a real secret (openssl rand -hex 32) and set it with wrangler secret put TOLLBOOTH_SECRET.", { status: 500 });
85
+ }
77
86
  // F05: pick the replay store, strongest first. A Durable Object
78
87
  // (TOLLBOOTH_REPLAY) gives ATOMIC, strict single-use across isolates and
79
88
  // locations. KV is a best-effort fallback (eventually consistent; concurrent
@@ -151,9 +160,10 @@ export default {
151
160
  target.protocol = origin.protocol;
152
161
  target.hostname = origin.hostname;
153
162
  target.port = origin.port;
154
- // Strip client-forgeable trust/forwarding headers before forwarding to origin.
163
+ // Strip client-forgeable trust/forwarding/client-IP headers before
164
+ // forwarding to origin (the same list the Node proxy strips).
155
165
  const headers = new Headers(request.headers);
156
- for (const h of ["x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded"]) headers.delete(h);
166
+ for (const h of CLIENT_TRUST_HEADERS) headers.delete(h);
157
167
  headers.set("x-forwarded-for", request.headers.get("cf-connecting-ip") || "");
158
168
  const init = { method: request.method, headers, redirect: "manual" };
159
169
  if (request.method !== "GET" && request.method !== "HEAD") init.body = request.body;