agent402-tollbooth 0.6.2 → 0.7.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
@@ -1,9 +1,12 @@
1
1
  # agent402-tollbooth
2
2
 
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** - either in
5
- USDC over the [x402 protocol](https://x402.org), or for free by solving a
3
+ **Open-source, self-hostable pay-per-crawl for x402 and MPP. Put it in front of
4
+ any site or API: humans browse free, AI crawlers and agents pay per request** -
5
+ in USDC over the [x402 protocol](https://x402.org), over
6
+ [MPP](https://mpp.dev) (the Machine Payments Protocol `Payment` HTTP auth
7
+ scheme, settled through the same stack), or for free by solving a
6
8
  proof-of-work. No Cloudflare, no Stripe, no Merchant-of-Record, no signup.
9
+ The first self-hostable pay-per-crawl gate that speaks both wires on one 402.
7
10
 
8
11
  The big platforms are converging on the same model: Cloudflare's
9
12
  [pay-per-crawl](https://stackoverflow.blog/2026/02/26/how-pay-per-crawl-is-reshaping-data-monetization/)
@@ -142,37 +145,65 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)
142
145
  > On the edge, pass a stable `secret` (PoW tokens are HMAC-signed). For
143
146
  > single-use replay protection across stateless invocations, supply a `store`
144
147
  > (e.g. a Cloudflare KV wrapper - the Worker entry wires this for you).
148
+ > The `x402:` middleware mode and MPP are Node/Express features today; the
149
+ > edge gate offers proof-of-work plus the `verifyX402` callback.
145
150
 
146
- ## Accepting USDC (x402)
151
+ ## Accepting USDC: x402 and MPP on the same 402
147
152
 
148
- The proof-of-work rail works with **zero config**. To also settle real USDC,
149
- set `payTo` and supply `verifyX402` - wire it to the standard, audited x402
150
- server stack (`@x402/express` / your facilitator) rather than reinventing
151
- settlement:
153
+ The proof-of-work rail works with **zero config**. To also settle real money,
154
+ hand the gate your standard, audited x402 middleware (`@x402/express` v2) as
155
+ `x402:` - the gate never reinvents settlement, it delegates to it:
152
156
 
153
157
  ```js
154
- import { createTollbooth, x402VerifierFromExpress } from "agent402-tollbooth";
155
- import { paymentMiddleware } from "x402-express"; // or @x402/express
156
- const x402 = paymentMiddleware(/* your wallet + facilitator config */);
157
-
158
- app.use(createTollbooth({
159
- payTo: "0xYourWallet",
160
- network: "base",
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 }),
165
- }));
158
+ import { createTollbooth } from "agent402-tollbooth";
159
+ import { paymentMiddleware } from "@x402/express";
160
+ import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
161
+ import { ExactEvmScheme } from "@x402/evm/exact/server";
162
+
163
+ const server = new x402ResourceServer(new HTTPFacilitatorClient({ url: "https://x402.org/facilitator" }))
164
+ .register("eip155:8453", new ExactEvmScheme());
165
+ const x402 = paymentMiddleware(
166
+ { "GET /*": { accepts: [{ scheme: "exact", network: "eip155:8453", price: "$0.001", payTo: "0xYourWallet" }] } },
167
+ server,
168
+ );
169
+
170
+ app.use(createTollbooth({ payTo: "0xYourWallet", x402 }));
166
171
  ```
167
172
 
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.)
173
+ What you get on every charged request:
174
+
175
+ - **x402 v2 clients pay.** The gate lifts the middleware's `PAYMENT-REQUIRED`
176
+ header onto its own 402, so a stock `@x402/fetch` client negotiates and pays
177
+ as if the middleware were mounted bare. Verify, your handler, and settlement
178
+ run in the middleware's own order (v2 settles after the handler ends the
179
+ response) - exactly once.
180
+ - **MPP clients pay** (default on when `x402` is set; `mpp: false` to turn off).
181
+ The same 402 gains `WWW-Authenticate: Payment` challenges (`evm`/`charge`,
182
+ one per EVM chain the middleware advertises among `mppNetworks`, default
183
+ Base + Celo). An inbound `Authorization: Payment` credential whose challenge
184
+ id HMAC-verifies is re-encoded as `PAYMENT-SIGNATURE` and settled by the
185
+ same middleware; the settled response carries a `Payment-Receipt`. A stock
186
+ [`mppx`](https://www.npmjs.com/package/mppx) client works unmodified. The
187
+ codec is dependency-free (no mppx on the server side) and byte-compatible
188
+ with the reference client - proven in the parent repo's CI by a real mppx
189
+ purchase through this package.
190
+ - **Proof-of-work first**, unchanged: a walletless agent always has a free path.
191
+
192
+ Set a stable `TOLLBOOTH_SECRET` (or `mppSecret`) so MPP challenge ids verify
193
+ across workers - the same caveat as proof-of-work tokens.
194
+
195
+ ### `verifyX402` (legacy, deprecated in 0.7.0)
196
+
197
+ Older versions took a verify-only callback (`verifyX402(req, opts) => boolean`)
198
+ and shipped `x402VerifierFromExpress()` to build one from a payment middleware.
199
+ **With `@x402/express` v2 that helper grants on verify and never settles**: v2
200
+ settles after the handler ends the response, and the helper handed it a stub
201
+ response the real handler never ended, so buyers were served and never charged
202
+ (measured in the parent repo's test suite). It only ever settled with v1
203
+ middlewares that settled before calling `next()`. It still works for those and
204
+ for custom verifiers you wrote yourself, and now warns once at construction;
205
+ everyone else should pass the middleware as `x402:` above. `verifyX402` still
206
+ receives `opts.signal` (aborted on `TOLLBOOTH_VERIFY_TIMEOUT_MS`).
176
207
 
177
208
  ## Configuration
178
209
 
@@ -190,7 +221,11 @@ on abort. (PoW is checked first, so an agent without a wallet always has a free
190
221
  | `botUserAgents` | `AI_BOTS` | User-agents to charge in `"bots"` mode |
191
222
  | `charge(req)` | mode | Custom "should this client pay?" predicate (wins over `mode`) |
192
223
  | `free(req)` | – | Custom force-allow predicate (wins over everything) |
193
- | `verifyX402(req, reqs)` | – | Async USDC settlement check (return `true` to allow) |
224
+ | `x402` | – | Your `@x402/express` `paymentMiddleware(...)`: owns verify + settle; enables x402 v2 clients and (by default) MPP |
225
+ | `mpp` | `true` when `x402` set | Accept MPP (`WWW-Authenticate: Payment` / `Authorization: Payment`) through the `x402` middleware |
226
+ | `mppSecret` | `powSecret` / `TOLLBOOTH_SECRET` | HMAC secret binding MPP challenge ids (stable across workers) |
227
+ | `mppNetworks` | `[8453, 42220]` | EVM chain ids to offer as MPP challenges (`"all"` = every EVM chain the middleware advertises) |
228
+ | `verifyX402(req, opts)` | – | Legacy verify-only USDC check (deprecated for settle-after-handler middlewares; see above) |
194
229
  | `resourceBaseUrl` | `""` | Absolute base used for the `resource` field / PoW binding |
195
230
  | `observe` | `false` | Observe-only: classify and count, but never 402. For pre-launch traffic measurement. |
196
231
  | `statsSink` | in-memory | Durable stats backend. Built-ins: `memorySink`, `kvStatsSink(kv)`, `httpStatsSink(url)`. |
@@ -206,6 +241,9 @@ Read by the bundled proxy / Express entry point (`index.js`):
206
241
  | `TOLLBOOTH_PAYTO` | – | Wallet address; set to advertise a USDC x402 quote |
207
242
  | `TOLLBOOTH_PRICE` | `"$0.001"` | Advertised price per request |
208
243
  | `TOLLBOOTH_NETWORK` | `"base"` | x402 network |
244
+ | `TOLLBOOTH_MPP` | on when `x402` set | `false` to switch MPP off |
245
+ | `TOLLBOOTH_MPP_SECRET` | `TOLLBOOTH_SECRET` | HMAC secret for MPP challenge ids |
246
+ | `TOLLBOOTH_MPP_NETWORKS` | `8453,42220` | CSV of EVM chain ids to offer as MPP challenges, or `all` |
209
247
  | `TOLLBOOTH_ASSET` | `"USDC"` | Asset symbol in the quote (`USDG` charges in USDG on Robinhood Chain) |
210
248
  | `TOLLBOOTH_POW_BITS` | `18` | Proof-of-work difficulty in leading zero bits |
211
249
  | `TOLLBOOTH_MODE` | `"bots"` | Who pays: `bots` · `all` · `strict` |
@@ -273,7 +311,7 @@ changes. Bots see a `X-Tollbooth-Observed: would-charge` header in observe mode
273
311
  ## Analytics
274
312
 
275
313
  The middleware keeps aggregate counters (no per-request data):
276
- - `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, difficultyNow, observe }`.
314
+ - `gate.stats()` → sync, in-process mirror: `{ requests, freeAllowed, wouldCharge, charged, powSolved, x402Paid, mppPaid, difficultyNow, observe }`.
277
315
  - `gate.snapshot()` → async, reads from the configured durable sink (defaults to memory).
278
316
  - `gate.flush()` → flush any buffered deltas to the durable sink (call inside `ctx.waitUntil` on edge runtimes).
279
317
 
@@ -465,9 +503,12 @@ TOLLBOOTH_ASSET=USDG \
465
503
  npx agent402-tollbooth
466
504
  ```
467
505
 
468
- or in code: `createTollbooth({ payTo, network: "eip155:4663", asset: "USDG", verifyX402 })`.
469
- Wire `verifyX402` to an x402 facilitator that settles chain 4663. Defaults
470
- are unchanged (USDC on Base).
506
+ or in code: `createTollbooth({ payTo, network: "eip155:4663", asset: "USDG", x402 })`,
507
+ where `x402` is a `@x402/express` middleware whose facilitator settles chain
508
+ 4663 (advertise `eip155:4663` in its `accepts`). Defaults are unchanged (USDC
509
+ on Base). MPP challenges are only minted for chains in `mppNetworks` (Base +
510
+ Celo by default - what a stock mppx client can sign); add `4663` there or pass
511
+ `"all"` if your buyers' MPP clients can sign USDG on Robinhood Chain.
471
512
 
472
513
  ## Legal
473
514
 
package/dashboard.js CHANGED
@@ -101,7 +101,7 @@ export function dashboardHtml() {
101
101
  </div>
102
102
  <div class="probe">
103
103
  <div class="label"><span>KPI grep — single-line dashboard for a shell</span><button class="copy" data-target="p5">copy</button></div>
104
- <pre id="p5">curl -s &lt;origin&gt;/__tollbooth/stats | jq '{requests, charged, powSolved, x402Paid}'</pre>
104
+ <pre id="p5">curl -s &lt;origin&gt;/__tollbooth/stats | jq '{requests, charged, powSolved, x402Paid, mppPaid}'</pre>
105
105
  </div>
106
106
  </div>
107
107
  <footer>Aggregate counts only (no per-request data). Raw JSON: <a href="/__tollbooth/stats">/__tollbooth/stats</a>.</footer>
@@ -113,7 +113,8 @@ const cards=[
113
113
  ["wouldCharge","Would charge (observe)","accent"],
114
114
  ["charged","Charged (402)","accent"],
115
115
  ["powSolved","Proof-of-work paid","accent"],
116
- ["x402Paid","USDC paid (x402)","accent"],
116
+ ["x402Paid","Paid via x402","accent"],
117
+ ["mppPaid","Paid via MPP","accent"],
117
118
  ["difficultyNow","PoW difficulty",false]
118
119
  ];
119
120
  // Rolling poll-delta series for the sparkline. We retain up to MAX_POINTS
@@ -211,7 +212,7 @@ async function tick(){
211
212
  document.getElementById("botpct").textContent=pct+"%";
212
213
  // Paid conversion: of all requests, what share actually settled (either
213
214
  // PoW or USDC). Answers "is the gate converting traffic into payment?"
214
- var pow=Number(s.powSolved)||0, usd=Number(s.x402Paid)||0, paid=pow+usd;
215
+ var pow=Number(s.powSolved)||0, usd=(Number(s.x402Paid)||0)+(Number(s.mppPaid)||0), paid=pow+usd;
215
216
  var paidPct=reqs?Math.round((paid/reqs)*100):0;
216
217
  document.getElementById("paidpct").textContent=paidPct+"%";
217
218
  // Paid-in-USDC share: of *paid* requests, how many settled in USDC vs PoW.
@@ -224,7 +225,7 @@ async function tick(){
224
225
  // per-poll arrival rate. First tick seeds lastRequests/lastPaid with no
225
226
  // plot — we need a baseline to subtract on tick #2.
226
227
  var now=Number(s.requests); if(!Number.isFinite(now)) now=0;
227
- var nowPaid=(Number(s.powSolved)||0)+(Number(s.x402Paid)||0);
228
+ var nowPaid=(Number(s.powSolved)||0)+(Number(s.x402Paid)||0)+(Number(s.mppPaid)||0);
228
229
  if(lastRequests!==null){
229
230
  var delta=Math.max(0,now-lastRequests);
230
231
  var pDelta=Math.max(0,nowPaid-lastPaid);
package/index.js CHANGED
@@ -14,14 +14,17 @@
14
14
  // x402 server middleware / your facilitator — see README).
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { Readable } from "node:stream";
17
+ import { randomBytes } from "node:crypto";
17
18
  import { createPow } from "./pow.js";
18
19
  import { makeBotMatcher, AI_BOTS } from "./bots.js";
19
20
  import { memorySink } from "./sinks.js";
21
+ import { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, isMppCredential, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
20
22
 
21
23
  export { AI_BOTS, makeBotMatcher } from "./bots.js";
22
24
  export { createPow, leadingZeroBits } from "./pow.js";
23
25
  export { memorySink, kvStatsSink, httpStatsSink } from "./sinks.js";
24
26
  export { sqliteReplayStore, redisReplayStore } from "./replay.js";
27
+ export { challengesFromPaymentRequired, translateCredential, receiptFromPaymentResponse, DEFAULT_MPP_CHAIN_IDS } from "./mpp.js";
25
28
 
26
29
  const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
27
30
 
@@ -34,15 +37,31 @@ const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_
34
37
  // the gate hanging or produce a charged-then-denied result after the gate has
35
38
  // returned 402. Grants on next(); denies when the middleware writes a 402.
36
39
  //
40
+ // DEPRECATED (0.7.0) - use `createTollbooth({ x402: paymentMiddleware })` instead.
41
+ // @x402/express v2's default `authorization` flow verifies BEFORE the handler
42
+ // but SETTLES AFTER the handler ends the response (it wraps res.end and awaits
43
+ // it). This wrapper hands the middleware a stub response that the real handler
44
+ // never ends, so with a v2 middleware it grants on verify and the settlement
45
+ // never runs: the buyer is served and never charged. It only ever settled with
46
+ // v1 middlewares that settled before calling next(). Kept for those callers;
47
+ // warns once at construction. The `x402` option delegates to the middleware
48
+ // with the REAL response, so verify, handler and settle happen in the
49
+ // middleware's own order, exactly once.
50
+ //
37
51
  // LIMITATION (documented): @x402/express settles by BROADCASTING and exposes no
38
52
  // cancel hook, so this wrapper cannot abort an on-chain settle already in flight.
39
53
  // Keep TOLLBOOTH_VERIFY_TIMEOUT_MS comfortably above your settle latency so the
40
54
  // abort is a backstop, not the normal path; or front it with a facilitator that
41
55
  // supports cancellation.
56
+ let warnedVerifierFromExpress = false;
42
57
  export function x402VerifierFromExpress(paymentMiddleware, { timeoutMs } = {}) {
43
58
  if (typeof paymentMiddleware !== "function") {
44
59
  throw new TypeError("x402VerifierFromExpress: paymentMiddleware must be a function");
45
60
  }
61
+ if (!warnedVerifierFromExpress) {
62
+ warnedVerifierFromExpress = true;
63
+ console.warn("[agent402-tollbooth] x402VerifierFromExpress is deprecated: with @x402/express v2 (settle-after-handler) it grants on verify and never settles. Pass the middleware as createTollbooth({ x402: paymentMiddleware }) instead.");
64
+ }
46
65
  return (req, opts = {}) => new Promise((resolve, reject) => {
47
66
  const signal = opts?.signal;
48
67
  let done = false;
@@ -93,7 +112,16 @@ const STRIP_INBOUND = new Set([
93
112
  * @param {string[]} [config.botUserAgents] user-agents to charge (default: AI_BOTS)
94
113
  * @param {(req)=>boolean} [config.charge] custom "should this client pay?" predicate
95
114
  * @param {(req)=>boolean} [config.free] custom force-allow predicate (wins over charge)
96
- * @param {(req, requirements)=>boolean|Promise<boolean>} [config.verifyX402] USDC settlement check
115
+ * @param {(req, requirements)=>boolean|Promise<boolean>} [config.verifyX402] legacy verify-only USDC check (deprecated for settle-after-handler middlewares; see `x402`)
116
+ * @param {Function} [config.x402] an @x402/express `paymentMiddleware(...)` (or compatible (req,res,next)) that owns
117
+ * verify + settle. The gate delegates paid requests to it with the REAL response, so
118
+ * settlement runs in the middleware's own order (v2: after the handler), and it lifts
119
+ * the middleware's PAYMENT-REQUIRED onto the gate's 402 so stock x402 v2 clients can pay.
120
+ * @param {boolean} [config.mpp] accept MPP (Machine Payments Protocol) clients too - default true when `x402` is set.
121
+ * Adds WWW-Authenticate: Payment challenges to the 402 and settles Authorization: Payment
122
+ * credentials through the same `x402` middleware (evm/charge on the chains it advertises).
123
+ * @param {string} [config.mppSecret] HMAC secret binding MPP challenge ids (default: powSecret / TOLLBOOTH_SECRET, else per-process random)
124
+ * @param {number[]|"all"} [config.mppNetworks] EVM chain ids to offer as MPP challenges (default Base + Celo, what a stock mppx client can sign)
97
125
  * @param {string} [config.resourceBaseUrl] absolute base for the x402 `resource`/PoW binding
98
126
  * @param {string} [config.message] human-readable note included in the 402
99
127
  * @param {boolean} [config.observe] observe-only: classify, count, never 402 (deploy a week before enforcing)
@@ -128,6 +156,12 @@ export function createTollbooth(config = {}) {
128
156
  charge,
129
157
  free,
130
158
  verifyX402,
159
+ x402 = null,
160
+ mpp = config.mpp ?? (process.env.TOLLBOOTH_MPP ? process.env.TOLLBOOTH_MPP !== "false" : !!x402),
161
+ mppSecret,
162
+ mppNetworks = process.env.TOLLBOOTH_MPP_NETWORKS
163
+ ? (process.env.TOLLBOOTH_MPP_NETWORKS.trim().toLowerCase() === "all" ? "all" : process.env.TOLLBOOTH_MPP_NETWORKS.split(",").map((s) => Number(s.trim())).filter(Number.isInteger))
164
+ : DEFAULT_MPP_CHAIN_IDS,
131
165
  resourceBaseUrl = process.env.TOLLBOOTH_RESOURCE_BASE || "",
132
166
  // Adaptive proof-of-work: raise difficulty as charged-request load climbs, so
133
167
  // high-volume abuse pays escalating CPU regardless of how it disguises itself.
@@ -147,6 +181,18 @@ export function createTollbooth(config = {}) {
147
181
 
148
182
  const isBot = makeBotMatcher(botUserAgents);
149
183
  const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret, replayStore }) : null;
184
+ if (x402 && typeof x402 !== "function") throw new TypeError("createTollbooth: `x402` must be an Express middleware function");
185
+ // MPP rides the x402 middleware (settlement authority) - without one there
186
+ // is nothing to settle an MPP credential with, so it stays off.
187
+ const mppOn = !!(mpp && x402);
188
+ // Challenge ids are HMAC-bound; a stable secret is needed across workers.
189
+ // Same caveat as PoW: per-process random works for one process only.
190
+ const mppKey = mppOn
191
+ ? String(mppSecret || powSecret || process.env.TOLLBOOTH_MPP_SECRET || process.env.TOLLBOOTH_SECRET || randomBytes(32).toString("hex"))
192
+ : null;
193
+ if (mppOn && !(mppSecret || powSecret || process.env.TOLLBOOTH_MPP_SECRET || process.env.TOLLBOOTH_SECRET)) {
194
+ console.warn("[agent402-tollbooth] MPP enabled with a per-process random secret - set TOLLBOOTH_SECRET (or mppSecret) for multi-worker deploys.");
195
+ }
150
196
 
151
197
  // Passive analytics — never affects request handling, just counts what happens.
152
198
  // `mem` is an always-on in-process mirror so `.stats()` stays synchronous for
@@ -218,7 +264,10 @@ export function createTollbooth(config = {}) {
218
264
  // stays a plain URL (below) for wire compatibility.
219
265
  const powResource = `${(req.method || "GET").toUpperCase()} ${resource}`;
220
266
 
221
- const send402 = (extra = {}) => {
267
+ // `headers` = extra response headers to carry on the 402 (the x402
268
+ // middleware's own PAYMENT-REQUIRED and, when MPP is on, the derived
269
+ // WWW-Authenticate: Payment challenges).
270
+ const send402 = (extra = {}, headers = {}) => {
222
271
  incr("charged");
223
272
  if (adaptive) chargedWindow.push(Date.now());
224
273
  const body = {
@@ -230,14 +279,131 @@ export function createTollbooth(config = {}) {
230
279
  ...extra,
231
280
  };
232
281
  if (powEngine) body.proofOfWork = powEngine.challenge(powResource, difficultyNow());
282
+ for (const [k, v] of Object.entries(headers)) { if (v != null && v !== "") res.setHeader(k, v); }
233
283
  res.status(402).json(body);
234
284
  };
235
285
 
286
+ // Paid rail, middleware mode: delegate to the operator's x402 middleware
287
+ // with the REAL response so verify -> handler -> settle run in ITS order,
288
+ // exactly once. Its 402 (no/invalid payment) is intercepted and replaced by
289
+ // the gate's 402 - same body contract as ever (accepts + proofOfWork +
290
+ // message) plus the middleware's PAYMENT-REQUIRED header lifted verbatim,
291
+ // so a stock x402 v2 client can pay a tollbooth-gated site, and - when MPP
292
+ // is on - WWW-Authenticate: Payment challenges derived from that same
293
+ // header, so a stock mppx client can too. Anything the middleware writes
294
+ // that is NOT a 402 (facilitator 5xx, its own paywall HTML) passes through
295
+ // untouched: it is the operator's stack answering, not ours to rewrite.
296
+ const middlewareRail = () => {
297
+ // ---- MPP inbound: Authorization: Payment -> PAYMENT-SIGNATURE ----
298
+ // Only when the request is not already speaking x402 (pass-through is
299
+ // the no-regression rule), and only a credential whose challenge id
300
+ // HMAC-verifies as OURS. Anything else is ignored: the middleware
301
+ // answers 402 and the interceptor below mints fresh challenges.
302
+ let viaMpp = false;
303
+ if (mppOn && !req.headers["payment-signature"] && !req.headers["x-payment"] && isMppCredential(req.headers.authorization)) {
304
+ const sig = translateCredential(req.headers.authorization, { secretKey: mppKey });
305
+ if (sig) {
306
+ req.headers["payment-signature"] = sig;
307
+ delete req.headers.authorization; // consumed - never let it be double-read downstream
308
+ viaMpp = true;
309
+ }
310
+ }
311
+ // ---- MPP outbound receipt: mirror PAYMENT-RESPONSE as Payment-Receipt ----
312
+ // The middleware sets PAYMENT-RESPONSE after settlement and then replays
313
+ // the handler's buffered writeHead, so hooking writeHead HERE (before the
314
+ // middleware wraps it) sees the settled header at replay time.
315
+ if (viaMpp) {
316
+ const origWriteHead = res.writeHead;
317
+ res.writeHead = function tollboothMppWriteHead(...args) {
318
+ try {
319
+ if (res.statusCode === 200 && !res.getHeader("Payment-Receipt")) {
320
+ const settle = res.getHeader("PAYMENT-RESPONSE") || res.getHeader("payment-response");
321
+ const receipt = settle ? receiptFromPaymentResponse(String(settle)) : null;
322
+ if (receipt) res.setHeader("Payment-Receipt", receipt);
323
+ }
324
+ } catch { /* receipts are additive - never break the response */ }
325
+ return origWriteHead.apply(this, args);
326
+ };
327
+ }
328
+ // ---- 402 interceptor around the real response ----
329
+ // The middleware answers "no/invalid payment" with res.status(402) +
330
+ // setHeader(PAYMENT-REQUIRED ...) + json({}) (or send(html) for its
331
+ // paywall). Swallow THAT write and emit the gate's 402 instead, carrying
332
+ // the headers it set. Everything else reaches the real response.
333
+ let intercepting = true;
334
+ let granted = false;
335
+ const realStatus = res.status.bind(res);
336
+ const realJson = res.json.bind(res);
337
+ const realSend = res.send.bind(res);
338
+ const realEnd = res.end.bind(res);
339
+ const restore = () => { intercepting = false; res.status = realStatus; res.json = realJson; res.send = realSend; res.end = realEnd; };
340
+ const finish402 = () => {
341
+ // Collect the middleware's 402 headers before restoring; the gate's
342
+ // own send402 re-applies them on top of its body.
343
+ const lifted = {};
344
+ for (const name of ["PAYMENT-REQUIRED", "payment-required", "X-PAYMENT-REQUIRED", "x-payment-required"]) {
345
+ const v = res.getHeader(name);
346
+ if (v) lifted[name.toUpperCase() === "PAYMENT-REQUIRED" ? "PAYMENT-REQUIRED" : name] = String(v);
347
+ }
348
+ if (mppOn && lifted["PAYMENT-REQUIRED"]) {
349
+ const www = challengesFromPaymentRequired(lifted["PAYMENT-REQUIRED"], { secretKey: mppKey, realm: mppRealm(req), chainIds: mppNetworks });
350
+ if (www) lifted["WWW-Authenticate"] = www;
351
+ }
352
+ restore();
353
+ // The middleware may have set a Content-Type for its own body; the
354
+ // gate answers JSON.
355
+ try { res.removeHeader("Content-Type"); } catch { /* ignore */ }
356
+ send402({}, lifted);
357
+ };
358
+ // Pass-through never reassigns res.* here: on grant, @x402/express has
359
+ // already captured these guards as its "original" methods and installed
360
+ // its own buffering wrappers on top (that is how settle-after-handler
361
+ // works). Reassigning would clobber those wrappers and the middleware
362
+ // would wait forever for a res.end it never sees - i.e. serve without
363
+ // settling. Only the 402 path restores (nothing is wrapped there).
364
+ const guard = (write) => (...args) => {
365
+ if (intercepting && !granted && res.statusCode === 402) { finish402(); return res; }
366
+ return write(...args);
367
+ };
368
+ res.status = (code) => { realStatus(code); return res; };
369
+ res.json = guard(realJson);
370
+ res.send = guard(realSend);
371
+ res.end = guard(realEnd);
372
+ const grant = () => {
373
+ granted = true;
374
+ intercepting = false;
375
+ incr(viaMpp ? "mppPaid" : "x402Paid");
376
+ res.setHeader("X-Tollbooth-Paid", viaMpp ? "mpp" : "x402");
377
+ return next();
378
+ };
379
+ let out;
380
+ try {
381
+ out = x402(req, res, grant);
382
+ } catch (e) {
383
+ restore();
384
+ res.setHeader("X-Tollbooth-Error", "x402-middleware-threw");
385
+ return send402();
386
+ }
387
+ return Promise.resolve(out).catch(() => {
388
+ if (granted || res.headersSent) return; // the middleware's own settle-failure 402 already went out
389
+ restore();
390
+ res.setHeader("X-Tollbooth-Error", "x402-middleware-threw");
391
+ send402();
392
+ });
393
+ };
394
+ const mppRealm = (r) => {
395
+ // Protection-space identifier for the challenge: the site's host, or the
396
+ // configured resource base's host - the same origin the PoW binds.
397
+ try { if (resourceBaseUrl) return new URL(resourceBaseUrl).host; } catch { /* fall through */ }
398
+ return String(r.headers?.host || (r.get && r.get("host")) || "tollbooth").toLowerCase();
399
+ };
400
+
236
401
  // Paid rail: x402 (USDC). Settlement verification is operator-supplied so
237
402
  // we reuse the standard, audited x402 stack rather than reinvent it. Reached
238
403
  // either directly or after the free rail declined, so it lives in a function
239
404
  // the (possibly async) proof-of-work branch can hand control back to.
240
405
  const paidRail = () => {
406
+ if (x402) return middlewareRail();
241
407
  const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
242
408
  if (payTo && typeof verifyX402 === "function" && payHeader) {
243
409
  // Bound verification time so a slow/hung verifier can't exhaust resources.
package/mpp.js ADDED
@@ -0,0 +1,176 @@
1
+ // MPP (Machine Payments Protocol) for the tollbooth - dependency-free codec.
2
+ //
3
+ // MPP is the IETF-track "Payment" HTTP authentication scheme (tempoxyz/mpp,
4
+ // paymentauth.org): the same 402 lifecycle as x402 with standard headers:
5
+ //
6
+ // challenge WWW-Authenticate: Payment id="...", realm="...", method="evm",
7
+ // intent="charge", request="<b64url JSON>", expires="...", opaque="..."
8
+ // credential Authorization: Payment <b64url JSON{challenge, payload, source?}>
9
+ // receipt Payment-Receipt: <b64url JSON{method, status, reference, timestamp}>
10
+ //
11
+ // MPP's `evm` charge method is the same primitive as x402 `exact` on EVM
12
+ // (an EIP-3009 transferWithAuthorization signed by the buyer), so a tollbooth
13
+ // that already settles x402 through the operator's @x402/express middleware can
14
+ // accept MPP clients as pure header translation - settlement authority stays
15
+ // with the x402 stack, exactly once per purchase:
16
+ //
17
+ // OUTBOUND the middleware's own PAYMENT-REQUIRED header (its advertised
18
+ // `accepts`) is turned into one HMAC-bound MPP challenge per
19
+ // eligible EVM entry; the verbatim accepts entry rides in the
20
+ // challenge's opaque slot so inbound is stateless and byte-exact.
21
+ // INBOUND an Authorization: Payment credential whose challenge id
22
+ // HMAC-verifies is re-encoded as a PAYMENT-SIGNATURE header and
23
+ // handed to the SAME middleware, which verifies + settles it as if
24
+ // an x402 client had sent it.
25
+ // RECEIPT the settled PAYMENT-RESPONSE is mirrored as an MPP Payment-Receipt.
26
+ //
27
+ // No mppx dependency: it pulls in ox/zod and a viem peer, too heavy for a
28
+ // drop-in middleware, and the wire is small. Byte-compatibility with the
29
+ // reference client is proven by scripts/test-tollbooth-mpp.js in the parent
30
+ // repo, which drives a REAL mppx client through this codec.
31
+ import { createHmac, timingSafeEqual } from "node:crypto";
32
+
33
+ const SCHEME = "Payment";
34
+ const META_ACCEPTS_KEY = "x402";
35
+ const STABLECOIN_DECIMALS = 6; // Circle USDC + Paxos USDG on every EVM rail
36
+ // What a stock mppx client can auto-sign (its built-in asset registry covers
37
+ // Base + Celo mainnets); every extra challenge costs ~800 bytes on every 402.
38
+ export const DEFAULT_MPP_CHAIN_IDS = [8453, 42220];
39
+
40
+ // ---- encoding primitives ---------------------------------------------------
41
+ export const b64url = (s) => Buffer.from(s, "utf8").toString("base64url");
42
+ export const unb64url = (s) => Buffer.from(String(s), "base64url").toString("utf8");
43
+ const b64std = (s) => Buffer.from(s, "utf8").toString("base64");
44
+ const unb64std = (s) => Buffer.from(String(s), "base64").toString("utf8");
45
+
46
+ /** RFC 8785-style canonical JSON (sorted object keys, no whitespace) - what the
47
+ * reference implementation uses for the `request`/`opaque` slots. */
48
+ export function canonicalJson(value) {
49
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
50
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
51
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
52
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
53
+ }
54
+ const encodeRequest = (obj) => b64url(canonicalJson(obj));
55
+
56
+ // Positional HMAC binding of the challenge id - the same slot layout the
57
+ // reference implementation uses, so its Challenge.verify() agrees with ours
58
+ // given the same secret (handy in tests; the spec only requires that WE bind).
59
+ function challengeId(secretKey, c) {
60
+ const input = [c.realm, c.method, c.intent, c.request, c.expires ?? "", c.digest ?? "", c.opaque ?? ""].join("|");
61
+ return createHmac("sha256", Buffer.from(secretKey, "utf8")).update(input, "utf8").digest("base64url");
62
+ }
63
+ function idMatches(secretKey, c) {
64
+ const a = Buffer.from(String(c.id || ""), "utf8");
65
+ const b = Buffer.from(challengeId(secretKey, c), "utf8");
66
+ return a.length === b.length && timingSafeEqual(a, b);
67
+ }
68
+ const authParam = (name, value) => {
69
+ const v = String(value);
70
+ if (/[\r\n]/.test(v)) throw new Error("invalid auth-param value");
71
+ return `${name}="${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
72
+ };
73
+
74
+ // ---- OUTBOUND: PAYMENT-REQUIRED -> WWW-Authenticate: Payment ---------------
75
+ /**
76
+ * Decode an x402 v2 PAYMENT-REQUIRED header (base64 JSON envelope) and mint one
77
+ * HMAC-bound MPP evm/charge challenge per eligible EVM `accepts` entry.
78
+ * @returns {string|null} WWW-Authenticate value, or null when nothing qualifies.
79
+ */
80
+ export function challengesFromPaymentRequired(paymentRequiredHeader, { secretKey, realm, chainIds = DEFAULT_MPP_CHAIN_IDS } = {}) {
81
+ if (!secretKey || !paymentRequiredHeader) return null;
82
+ let envelope;
83
+ try { envelope = JSON.parse(unb64std(paymentRequiredHeader)); } catch { return null; }
84
+ const accepts = Array.isArray(envelope?.accepts) ? envelope.accepts : [];
85
+ const allowAll = chainIds === "all";
86
+ const allowed = new Set(allowAll ? [] : (chainIds || []).map(Number));
87
+ const out = [];
88
+ for (const a of accepts) {
89
+ if (!a || a.scheme !== "exact" || typeof a.network !== "string" || !a.network.startsWith("eip155:")) continue;
90
+ const chainId = Number(a.network.slice("eip155:".length));
91
+ if (!Number.isInteger(chainId)) continue;
92
+ if (!allowAll && !allowed.has(chainId)) continue;
93
+ if (typeof a.amount !== "string" || typeof a.asset !== "string" || typeof a.payTo !== "string") continue;
94
+ const timeout = Number(a.maxTimeoutSeconds) > 0 ? Number(a.maxTimeoutSeconds) : 300;
95
+ const c = {
96
+ realm,
97
+ method: "evm",
98
+ intent: "charge",
99
+ request: encodeRequest({
100
+ amount: a.amount,
101
+ currency: a.asset,
102
+ recipient: a.payTo,
103
+ methodDetails: { chainId, credentialTypes: ["authorization"], decimals: STABLECOIN_DECIMALS },
104
+ }),
105
+ // Native MPP clients sign validBefore = expires; keep it inside the
106
+ // advertised x402 window so facilitator timeout semantics match.
107
+ expires: new Date(Date.now() + timeout * 1000).toISOString(),
108
+ // The verbatim accepts entry (RAW, not normalised - the middleware's
109
+ // requirement matching deep-equals the full advertised object).
110
+ opaque: encodeRequest({ [META_ACCEPTS_KEY]: JSON.stringify(a) }),
111
+ };
112
+ c.id = challengeId(secretKey, c);
113
+ out.push([
114
+ authParam("id", c.id), authParam("realm", c.realm), authParam("method", c.method), authParam("intent", c.intent),
115
+ authParam("request", c.request), authParam("expires", c.expires), authParam("opaque", c.opaque),
116
+ ].join(", "));
117
+ }
118
+ if (!out.length) return null;
119
+ return out.map((p) => `${SCHEME} ${p}`).join(", ");
120
+ }
121
+
122
+ // ---- INBOUND: Authorization: Payment -> PAYMENT-SIGNATURE ------------------
123
+ const HEX_ADDR = /^0x[0-9a-fA-F]{40}$/;
124
+ const HEX_32 = /^0x[0-9a-fA-F]{64}$/;
125
+ const HEX_SIG = /^0x[0-9a-fA-F]+$/;
126
+ const UINT = /^\d+$/;
127
+
128
+ /**
129
+ * Validate an MPP credential against our HMAC binding and re-encode it as an
130
+ * x402 v2 PAYMENT-SIGNATURE value. Returns null for anything that is not a
131
+ * valid, unexpired, HMAC-bound evm/charge credential of ours.
132
+ */
133
+ export function translateCredential(authorizationHeader, { secretKey } = {}) {
134
+ if (!secretKey || typeof authorizationHeader !== "string") return null;
135
+ const m = authorizationHeader.match(/^Payment\s+([A-Za-z0-9_-]+=*)\s*$/i);
136
+ if (!m) return null;
137
+ let wire;
138
+ try { wire = JSON.parse(unb64url(m[1])); } catch { return null; }
139
+ const ch = wire?.challenge;
140
+ if (!ch || typeof ch !== "object") return null;
141
+ if (ch.method !== "evm" || ch.intent !== "charge") return null;
142
+ if (typeof ch.request !== "string" || typeof ch.opaque !== "string") return null;
143
+ // HMAC binding (spec: servers MUST bind ids to challenge params). This also
144
+ // proves the echoed accepts entry in opaque is ours and untampered.
145
+ if (!idMatches(secretKey, ch)) return null;
146
+ if (ch.expires && !(Date.parse(ch.expires) > Date.now())) return null;
147
+ let accepted;
148
+ try {
149
+ const meta = JSON.parse(unb64url(ch.opaque));
150
+ accepted = JSON.parse(meta[META_ACCEPTS_KEY]);
151
+ } catch { return null; }
152
+ if (!accepted || typeof accepted !== "object") return null;
153
+ const p = wire.payload;
154
+ if (!p || p.type !== "authorization") return null;
155
+ const { from, to, value, validAfter, validBefore, nonce, signature } = p;
156
+ if (!HEX_ADDR.test(String(from)) || !HEX_ADDR.test(String(to)) || !HEX_32.test(String(nonce)) || !HEX_SIG.test(String(signature))) return null;
157
+ if (!UINT.test(String(value)) || !UINT.test(String(validAfter)) || !UINT.test(String(validBefore))) return null;
158
+ return b64std(JSON.stringify({
159
+ x402Version: 2,
160
+ accepted,
161
+ payload: { authorization: { from, to, value: String(value), validAfter: String(validAfter), validBefore: String(validBefore), nonce }, signature },
162
+ }));
163
+ }
164
+
165
+ // ---- RECEIPT: PAYMENT-RESPONSE -> Payment-Receipt -------------------------
166
+ export function receiptFromPaymentResponse(paymentResponseHeader) {
167
+ let settle;
168
+ try { settle = JSON.parse(unb64std(paymentResponseHeader)); } catch { return null; }
169
+ if (!settle || settle.success !== true || typeof settle.transaction !== "string") return null;
170
+ return b64url(JSON.stringify({ method: "evm", status: "success", reference: settle.transaction, timestamp: new Date().toISOString() }));
171
+ }
172
+
173
+ /** True when the header looks like an MPP credential (scheme check only). */
174
+ export function isMppCredential(authorizationHeader) {
175
+ return typeof authorizationHeader === "string" && /^Payment\s+\S/i.test(authorizationHeader);
176
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.6.2",
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.",
3
+ "version": "0.7.0",
4
+ "description": "Open-source, self-hostable pay-per-crawl tollbooth for x402 AND MPP: 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 - no Cloudflare, no Stripe, no signup.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -16,7 +16,9 @@
16
16
  "./dashboard.js": "./dashboard.js",
17
17
  "./sinks.js": "./sinks.js",
18
18
  "./replay": "./replay.js",
19
- "./replay.js": "./replay.js"
19
+ "./replay.js": "./replay.js",
20
+ "./mpp": "./mpp.js",
21
+ "./mpp.js": "./mpp.js"
20
22
  },
21
23
  "files": [
22
24
  "index.js",
@@ -24,6 +26,7 @@
24
26
  "worker.js",
25
27
  "bots.js",
26
28
  "pow.js",
29
+ "mpp.js",
27
30
  "dashboard.js",
28
31
  "sinks.js",
29
32
  "replay.js",
@@ -65,7 +68,10 @@
65
68
  "tollbooth",
66
69
  "base",
67
70
  "robinhood-chain",
68
- "usdg"
71
+ "usdg",
72
+ "mpp",
73
+ "machine-payments-protocol",
74
+ "payment-auth"
69
75
  ],
70
76
  "author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
71
77
  "license": "MIT",
package/sinks.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // Want strong consistency on Cloudflare? Back it with a Durable Object that
17
17
  // implements the same { incr, snapshot } interface and pass it as `statsSink`.
18
18
 
19
- const KNOWN_FIELDS = ["requests", "freeAllowed", "wouldCharge", "charged", "powSolved", "x402Paid"];
19
+ const KNOWN_FIELDS = ["requests", "freeAllowed", "wouldCharge", "charged", "powSolved", "x402Paid", "mppPaid"];
20
20
  const ZERO = () => Object.fromEntries(KNOWN_FIELDS.map((k) => [k, 0]));
21
21
 
22
22
  // Filter an untrusted JSON blob (KV value or HTTP collector response) to only