agent402-tollbooth 0.10.6 → 0.10.8
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 +9 -3
- package/edge.js +6 -1
- package/index.js +8 -9
- package/package.json +6 -5
- package/strip-headers.js +19 -0
- package/worker.js +12 -2
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
|
|
275
|
-
|
|
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/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) <
|
|
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
|
@@ -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
|
-
|
|
99
|
-
|
|
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).
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.8",
|
|
4
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",
|
|
@@ -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"
|
|
@@ -52,10 +53,10 @@
|
|
|
52
53
|
"express": "^5.0.0"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
|
-
"@coinbase/x402": ">=2.0.0",
|
|
56
|
-
"@x402/core": ">=2.0.0",
|
|
57
|
-
"@x402/evm": ">=2.0.0",
|
|
58
|
-
"@x402/express": ">=2.0.0"
|
|
56
|
+
"@coinbase/x402": ">=2.0.0 <3",
|
|
57
|
+
"@x402/core": ">=2.0.0 <3",
|
|
58
|
+
"@x402/evm": ">=2.0.0 <3",
|
|
59
|
+
"@x402/express": ">=2.0.0 <3"
|
|
59
60
|
},
|
|
60
61
|
"peerDependenciesMeta": {
|
|
61
62
|
"@coinbase/x402": {
|
package/strip-headers.js
ADDED
|
@@ -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
|
|
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
|
|
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;
|