agent402-tollbooth 0.6.1 → 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 +74 -33
- package/dashboard.js +5 -4
- package/index.js +168 -2
- package/mpp.js +176 -0
- package/package.json +10 -4
- package/replay.js +23 -5
- package/sinks.js +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# agent402-tollbooth
|
|
2
2
|
|
|
3
|
-
**Open-source, self-hostable
|
|
4
|
-
API: humans browse free, AI crawlers and agents pay per request** -
|
|
5
|
-
USDC over the [x402 protocol](https://x402.org),
|
|
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
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
155
|
-
import { paymentMiddleware } from "x402
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
| `
|
|
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",
|
|
469
|
-
|
|
470
|
-
are unchanged (USDC
|
|
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 <origin>/__tollbooth/stats | jq '{requests, charged, powSolved, x402Paid}'</pre>
|
|
104
|
+
<pre id="p5">curl -s <origin>/__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","
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Open-source, self-hostable
|
|
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/replay.js
CHANGED
|
@@ -115,12 +115,29 @@ export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
|
|
|
115
115
|
// Detection above is a heuristic; this is the observation. Two claims of a
|
|
116
116
|
// throwaway key must produce a win and then a refusal. Anything else means
|
|
117
117
|
// single-use is not working, and the store refuses rather than pretending.
|
|
118
|
+
// Tri-state on PURPOSE: null = not yet observed, true = proven, false = proven
|
|
119
|
+
// BROKEN and permanently refused.
|
|
120
|
+
//
|
|
121
|
+
// A verdict of false must keep throwing on EVERY later call, not just the one
|
|
122
|
+
// that discovered it. Memoising false and returning it as a resolved value
|
|
123
|
+
// would make the store refuse exactly once and then grant every replay
|
|
124
|
+
// forever - the precise failure this self-test exists to prevent, reached one
|
|
125
|
+
// request later. A caller that solves one challenge could then replay that
|
|
126
|
+
// token for its whole TTL.
|
|
127
|
+
//
|
|
128
|
+
// A probe that THROWS (Redis unreachable) leaves the verdict at null so the
|
|
129
|
+
// next call re-probes: a transient outage must not be remembered as a verdict.
|
|
118
130
|
let nxProven = null;
|
|
131
|
+
const NX_BROKEN =
|
|
132
|
+
"redisReplayStore: SET NX is not honoured by this client, so proof-of-work single-use cannot be enforced. " +
|
|
133
|
+
"The gate refuses rather than granting replays. Pass a client whose SET supports NX, or supply your own { claim(token, expiresAtMs) } store.";
|
|
119
134
|
const proveNx = async () => {
|
|
120
|
-
if (nxProven
|
|
135
|
+
if (nxProven === true) return true;
|
|
136
|
+
if (nxProven === false) throw new Error(NX_BROKEN);
|
|
121
137
|
const probe = `${prefix}__nxselftest__${Math.random().toString(36).slice(2)}${Date.now()}`;
|
|
122
138
|
const px = 5000;
|
|
123
139
|
const set = (k) => (isIoredis ? client.set(k, "1", "PX", px, "NX") : client.set(k, "1", { NX: true, PX: px }));
|
|
140
|
+
// Deliberately NOT wrapped: a throw here leaves nxProven null (re-probe).
|
|
124
141
|
const first = await set(probe);
|
|
125
142
|
const second = await set(probe);
|
|
126
143
|
const firstWon = first === "OK" || first === true;
|
|
@@ -128,15 +145,16 @@ export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
|
|
|
128
145
|
nxProven = firstWon && secondRefused;
|
|
129
146
|
if (!nxProven) {
|
|
130
147
|
throw new Error(
|
|
131
|
-
|
|
132
|
-
"Proof-of-work single-use cannot be enforced against this client, so the gate refuses rather than granting replays."
|
|
148
|
+
`${NX_BROKEN} (first claim won: ${firstWon}, second refused: ${secondRefused})`
|
|
133
149
|
);
|
|
134
150
|
}
|
|
135
|
-
return
|
|
151
|
+
return true;
|
|
136
152
|
};
|
|
137
153
|
return {
|
|
138
154
|
claim: async (token, expiresAtMs) => {
|
|
139
|
-
|
|
155
|
+
// Respect the verdict: proveNx throws when unproven, but assert anyway so
|
|
156
|
+
// no future refactor can turn "unproven" into a silently ignored value.
|
|
157
|
+
if ((await proveNx()) !== true) throw new Error(NX_BROKEN);
|
|
140
158
|
const key = prefix + token;
|
|
141
159
|
// Floor of 1ms: Redis rejects a non-positive PX. pow.js has already
|
|
142
160
|
// refused anything actually expired, so a tiny window here just means the
|
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
|