agent402-tollbooth 0.6.0 → 0.6.2

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.
Files changed (3) hide show
  1. package/edge.js +36 -1
  2. package/package.json +1 -1
  3. package/replay.js +53 -0
package/edge.js CHANGED
@@ -130,6 +130,11 @@ export function createEdgeTollbooth(config = {}) {
130
130
  powDifficulty,
131
131
  secret,
132
132
  store,
133
+ // Same contract as the Node build's verifyX402: (request, requirements) =>
134
+ // boolean | Promise<boolean>. Without it this build has NO way to accept a
135
+ // payment, which is why the accepts block below is withheld when it is
136
+ // absent - see the note there.
137
+ verifyX402 = null,
133
138
  botUserAgents = AI_BOTS,
134
139
  // "bots" (default, charge AI crawler UAs) | "all" (charge all but free()) |
135
140
  // "strict" (charge anything that isn't a real-browser request). An explicit
@@ -148,6 +153,16 @@ export function createEdgeTollbooth(config = {}) {
148
153
  message = "This resource charges automated / AI clients per request. Humans browse free; bots pay in USDC via x402 or by solving a proof-of-work.",
149
154
  } = config;
150
155
 
156
+ // Say it at construction, not when a buyer gives up. An operator who sets a
157
+ // payTo here reasonably believes they are charging; without a verifier this
158
+ // build can only ever serve proof-of-work, and the price would be fiction.
159
+ if (payTo && !verifyX402) {
160
+ console.warn(
161
+ "agent402-tollbooth: payTo is set but no verifyX402 was supplied, so this edge build cannot accept a USDC payment. " +
162
+ "The USDC quote is being WITHHELD from the 402 (proof-of-work still works). Pass verifyX402 to charge, or drop payTo to stop implying it."
163
+ );
164
+ }
165
+
151
166
  const isBot = makeBotMatcher(botUserAgents);
152
167
  const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
153
168
  const mem = memorySink();
@@ -194,6 +209,21 @@ export function createEdgeTollbooth(config = {}) {
194
209
  // stays a plain URL for wire compatibility).
195
210
  const powResource = `${request.method || "GET"} ${resource}`;
196
211
 
212
+ // An x402 payment presented on either header name. @x402/core emits
213
+ // PAYMENT-SIGNATURE while older clients send X-PAYMENT, and a gate that
214
+ // reads only one of them refuses buyers who did everything right.
215
+ const paymentHeader = request.headers.get("payment-signature") || request.headers.get("x-payment");
216
+ if (paymentHeader && verifyX402) {
217
+ let paid = false;
218
+ try {
219
+ paid = await verifyX402(request, { price, network, asset, payTo, resource });
220
+ } catch {
221
+ // Fail CLOSED: a verifier that throws must not open the gate.
222
+ paid = false;
223
+ }
224
+ if (paid) { incr("paid"); return null; }
225
+ }
226
+
197
227
  const sol = request.headers.get("x-pow-solution");
198
228
  if (powEngine && sol) {
199
229
  const r = await powEngine.verify(sol, powResource);
@@ -204,7 +234,12 @@ export function createEdgeTollbooth(config = {}) {
204
234
  const body = {
205
235
  error: "Payment Required",
206
236
  message,
207
- accepts: payTo ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
237
+ // Only advertise USDC when this build can actually ACCEPT it. Emitting an
238
+ // accepts block with no verifier configured published a price that no
239
+ // code path could honour: a crawler that paid correctly was 402'd
240
+ // forever, money never moved, and the operator believed they were
241
+ // charging. Quoting a price you cannot take is worse than quoting none.
242
+ accepts: (payTo && verifyX402) ? [{ scheme: "exact", network, maxAmountRequired: String(price), asset, payTo, resource }] : [],
208
243
  };
209
244
  if (powEngine) body.proofOfWork = await powEngine.challenge(powResource);
210
245
  return new Response(JSON.stringify(body), {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/replay.js CHANGED
@@ -100,8 +100,61 @@ export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
100
100
  // mode of guessing wrong is not an exception but a SET without the NX flag,
101
101
  // which overwrites the record and grants every replay.
102
102
  const isIoredis = typeof client.defineCommand === "function";
103
+ const isNodeRedis = typeof client.sendCommand === "function" || typeof client.sSet === "function" || Boolean(client.options && client.options.socket);
104
+ if (!isIoredis && !isNodeRedis) {
105
+ // Fail CLOSED on an unrecognised client instead of guessing a SET shape.
106
+ // The failure mode of a wrong guess is not an exception: the flag argument
107
+ // is silently ignored, the key is overwritten every time, and single-use
108
+ // stops existing while everything still looks healthy.
109
+ throw new TypeError(
110
+ "redisReplayStore(client): could not identify this client as node-redis or ioredis. " +
111
+ "Pass one of those, or supply your own { claim(token, expiresAtMs) } store - guessing the SET argument shape risks dropping NX and granting every replay."
112
+ );
113
+ }
114
+ // Prove NX is actually being honoured, once, before this store is trusted.
115
+ // Detection above is a heuristic; this is the observation. Two claims of a
116
+ // throwaway key must produce a win and then a refusal. Anything else means
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.
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.";
134
+ const proveNx = async () => {
135
+ if (nxProven === true) return true;
136
+ if (nxProven === false) throw new Error(NX_BROKEN);
137
+ const probe = `${prefix}__nxselftest__${Math.random().toString(36).slice(2)}${Date.now()}`;
138
+ const px = 5000;
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).
141
+ const first = await set(probe);
142
+ const second = await set(probe);
143
+ const firstWon = first === "OK" || first === true;
144
+ const secondRefused = !(second === "OK" || second === true);
145
+ nxProven = firstWon && secondRefused;
146
+ if (!nxProven) {
147
+ throw new Error(
148
+ `${NX_BROKEN} (first claim won: ${firstWon}, second refused: ${secondRefused})`
149
+ );
150
+ }
151
+ return true;
152
+ };
103
153
  return {
104
154
  claim: async (token, expiresAtMs) => {
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);
105
158
  const key = prefix + token;
106
159
  // Floor of 1ms: Redis rejects a non-positive PX. pow.js has already
107
160
  // refused anything actually expired, so a tiny window here just means the