@forgemeshlabs/x402-ads-mcp 0.2.4 → 0.2.6

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/Dockerfile CHANGED
@@ -5,10 +5,9 @@ WORKDIR /app
5
5
  COPY package.json package-lock.json ./
6
6
  RUN npm ci --omit=dev
7
7
 
8
- COPY index.js README.md LICENSE server.json glama.json GLAMA.md ./
8
+ COPY index.js x402-guard.js README.md LICENSE server.json glama.json GLAMA.md ./
9
9
 
10
- ENV NODE_ENV=production \
11
- X402_ADS_BASE_URL=https://ads.forgemesh.io
10
+ ENV NODE_ENV=production
12
11
 
13
12
  USER node
14
13
 
package/README.md CHANGED
@@ -60,10 +60,10 @@ One $0.10 USDC payment on Base; the paying wallet becomes your identity and your
60
60
  |---|---|---|
61
61
  | `WALLET_PRIVATE_KEY` | no | Base mainnet wallet holding USDC; enables automatic settlement of paid tools |
62
62
  | `X402_ADS_PUBLISHER_KEY` | no | Publisher key from ads.forgemesh.io; free lane for your own reports |
63
- | `X402_ADS_BASE_URL` | no | Override the network base URL (default `https://ads.forgemesh.io`) |
64
- | `BASE_RPC_URL` | no | Override the Base RPC (default `https://mainnet.base.org`) |
63
+ | `X402_MAX_PRICE_USD` | no | Per-call price cap in USD; can only LOWER the built-in cap ($0.05, or $0.10 for `register`) |
64
+ | `X402_SESSION_BUDGET_USD` | no | Cumulative cap per process; can only LOWER the built-in $10 |
65
65
 
66
- Use a dedicated hot wallet holding only small working balances. The key never leaves your machine — payments are signed locally (EIP-3009) and settle on-chain.
66
+ The backend URL is fixed to `https://ads.forgemesh.io` (the publisher key is only ever sent there). The server refuses to sign for any payee other than the ForgeMesh Ads wallet, any network other than Base mainnet, any asset other than USDC, or any amount over the cap. Use a dedicated, low-balance wallet. The key never leaves your machine — payments are signed locally (EIP-3009) and settle on-chain.
67
67
 
68
68
  Ready-made configs live in [`examples/`](./examples): a Claude Desktop `mcpServers` block and a commented env-var template.
69
69
 
package/glama.json CHANGED
@@ -25,5 +25,6 @@
25
25
  "machine-commerce",
26
26
  "mcp",
27
27
  "autonomous-agents"
28
- ]
28
+ ],
29
+ "version": "0.2.6"
29
30
  }
package/index.js CHANGED
@@ -8,20 +8,23 @@ const { x402Client, x402HTTPClient } = require("@x402/core/client");
8
8
  const { ExactEvmScheme } = require("@x402/evm/exact/client");
9
9
  const { toClientEvmSigner } = require("@x402/evm");
10
10
  const { privateKeyToAccount } = require("viem/accounts");
11
- const { createPublicClient, http } = require("viem");
12
- const { base } = require("viem/chains");
11
+ const { createGuard } = require("./x402-guard");
13
12
 
14
- const BASE_URL = (process.env.X402_ADS_BASE_URL || "https://ads.forgemesh.io").replace(/\/+$/, "");
15
- const BASE_RPC_URL = process.env.BASE_RPC_URL || "https://mainnet.base.org";
13
+ const BASE_URL = "https://ads.forgemesh.io";
14
+ const ADS_PAY_TO = ["0x65E02cB7Fee27630bB7d1a3ADd02Ef7Aa2D21701"];
15
+ // Tool calls cap at the highest tool price ($0.05); the register CLI needs its own $0.10 cap.
16
+ const guard = createGuard({ baseUrl: BASE_URL, payTo: ADS_PAY_TO, maxPriceUsd: 0.05, sessionBudgetUsd: 10 });
17
+ const registerGuard = createGuard({ baseUrl: BASE_URL, payTo: ADS_PAY_TO, maxPriceUsd: 0.1, sessionBudgetUsd: 10 });
16
18
  const WINDOWS = ["24h", "7d", "30d", "all"];
19
+ const ID_PATTERN = /^[A-Za-z0-9._:@-]+$/; // service ids and categories: no slashes, spaces or control chars
17
20
 
18
21
  const TOOL_SCHEMAS = {
19
22
  list_tools: {},
20
23
  get_network_counters: {},
21
24
  preview_recommendations: {
22
- service: z.string().max(120).optional().describe("Your service identifier, used only for self-exclusion in results"),
23
- endpoint: z.string().max(300).optional().describe("The probed endpoint path, e.g. /api/forecast"),
24
- category: z.string().max(60).optional().describe("Category to match recommendations against, e.g. finance, blockchain, images"),
25
+ service: z.string().max(120).regex(ID_PATTERN).optional().describe("Your service identifier, used only for self-exclusion in results"),
26
+ endpoint: z.string().max(300).regex(/^\/[A-Za-z0-9/._~%-]*$/).optional().describe("The probed endpoint path, e.g. /api/forecast"),
27
+ category: z.string().max(60).regex(ID_PATTERN).optional().describe("Category to match recommendations against, e.g. finance, blockchain, images"),
25
28
  },
26
29
  get_network_stats: {},
27
30
  get_intent_trends: {
@@ -29,11 +32,11 @@ const TOOL_SCHEMAS = {
29
32
  limit: z.number().int().min(1).max(100).optional().describe("Max rows, 1-100 (default 20)"),
30
33
  },
31
34
  get_category_demand: {
32
- category: z.string().min(1).max(60).describe("Category to measure, e.g. finance, blockchain, images, tts"),
35
+ category: z.string().min(1).max(60).regex(ID_PATTERN).describe("Category to measure, e.g. finance, blockchain, images, tts"),
33
36
  window: z.enum(WINDOWS).optional().describe("Time window: 24h, 7d, 30d, or all (default 30d)"),
34
37
  },
35
38
  get_intent_report: {
36
- service: z.string().min(1).max(120).describe("Service identifier to report on"),
39
+ service: z.string().min(1).max(120).regex(ID_PATTERN).describe("Service identifier to report on"),
37
40
  window: z.enum(WINDOWS).optional().describe("Time window: 24h, 7d, 30d, or all (default 30d)"),
38
41
  },
39
42
  get_terms: {},
@@ -138,12 +141,13 @@ const TOOLS = [
138
141
  },
139
142
  ];
140
143
 
141
- function walletClient() {
144
+ function walletClient(g = guard) {
142
145
  const key = process.env.WALLET_PRIVATE_KEY;
143
146
  if (!key) return null;
144
147
  const pk = key.startsWith("0x") ? key : "0x" + key;
145
148
  const account = privateKeyToAccount(pk);
146
- const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account)));
149
+ const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account)))
150
+ .registerPolicy(g.policy);
147
151
  return new x402HTTPClient(coreClient);
148
152
  }
149
153
 
@@ -172,49 +176,26 @@ function slimChallenge(res, body) {
172
176
  };
173
177
  }
174
178
 
175
- async function createChainTimedPaymentPayload(httpClient, paymentRequired) {
176
- try {
177
- const publicClient = createPublicClient({ chain: base, transport: http(BASE_RPC_URL) });
178
- const block = await publicClient.getBlock();
179
- const chainNow = Number(block.timestamp);
180
- const originalNow = Date.now;
181
- const localNow = Math.floor(originalNow() / 1000);
182
- const timeout = Number(paymentRequired.accepts?.[0]?.maxTimeoutSeconds || 300);
183
- const signingNow = Math.min(Math.max(chainNow, localNow + 30 - timeout), chainNow + 600);
184
- Date.now = () => signingNow * 1000;
185
- try {
186
- return await httpClient.createPaymentPayload(paymentRequired);
187
- } finally {
188
- Date.now = originalNow;
189
- }
190
- } catch (_) {
191
- return httpClient.createPaymentPayload(paymentRequired);
192
- }
193
- }
194
-
195
179
  // Challenge-first paid GET: publisher free lane → settle if wallet → structured 402 otherwise.
196
180
  async function paidGet(path) {
197
181
  const headers = {};
198
182
  if (process.env.X402_ADS_PUBLISHER_KEY) headers["x-publisher-key"] = process.env.X402_ADS_PUBLISHER_KEY;
199
- const url = BASE_URL + path;
200
183
 
201
- const res = await fetch(url, { headers });
184
+ const res = await guard.fetchBounded(path, { headers });
202
185
  if (res.ok) {
203
186
  const viaPublisherKey = !!process.env.X402_ADS_PUBLISHER_KEY;
204
- return { paid: false, ...(viaPublisherKey ? { free_via_publisher_key: true } : {}), data: await res.json() };
205
- }
206
- if (res.status !== 402) {
207
- const text = await res.text().catch(() => "");
208
- throw new Error(`GET ${path} failed: ${res.status} ${text.slice(0, 240)}`);
187
+ return { paid: false, ...(viaPublisherKey ? { free_via_publisher_key: true } : {}), data: JSON.parse(res.text) };
209
188
  }
210
-
211
- let challengeBody;
212
- try {
213
- challengeBody = await res.clone().json();
214
- } catch (_) {}
189
+ if (res.status !== 402) throw new Error(`GET ${path} failed: ${res.status} ${res.text.slice(0, 200)}`);
215
190
 
216
191
  const httpClient = walletClient();
217
192
  if (!httpClient) {
193
+ let challengeBody;
194
+ try {
195
+ challengeBody = JSON.parse(res.text);
196
+ } catch (_) {
197
+ challengeBody = undefined; // header carries the full challenge; the body is only a summary
198
+ }
218
199
  return {
219
200
  payment_required: true,
220
201
  challenge: slimChallenge(res, challengeBody),
@@ -223,26 +204,14 @@ async function paidGet(path) {
223
204
  };
224
205
  }
225
206
 
226
- const paymentRequired = httpClient.getPaymentRequiredResponse((name) => res.headers.get(name), challengeBody);
227
- const paymentPayload = await createChainTimedPaymentPayload(httpClient, paymentRequired);
228
- const paidRes = await fetch(url, {
229
- headers: { ...headers, ...httpClient.encodePaymentSignatureHeader(paymentPayload) },
230
- });
231
- if (!paidRes.ok) {
232
- const text = await paidRes.text().catch(() => paidRes.statusText);
233
- throw new Error(`Paid call failed: ${paidRes.status} ${text.slice(0, 240)}`);
234
- }
235
- return {
236
- paid: true,
237
- payment_response: paidRes.headers.get("payment-response"),
238
- data: await paidRes.json(),
239
- };
207
+ const { _payment, ...data } = await guard.callPaid(httpClient, path, { headers });
208
+ return { paid: true, payment_response: _payment, data };
240
209
  }
241
210
 
242
211
  async function freeGet(path, asText = false) {
243
- const res = await fetch(BASE_URL + path);
212
+ const res = await guard.fetchBounded(path);
244
213
  if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
245
- return asText ? res.text() : res.json();
214
+ return asText ? res.text : JSON.parse(res.text);
246
215
  }
247
216
 
248
217
  function qs(params) {
@@ -260,7 +229,7 @@ async function callTool(name, args = {}) {
260
229
  if (name === "get_network_counters") return freeGet("/v1/counters");
261
230
 
262
231
  if (name === "preview_recommendations") {
263
- const res = await fetch(BASE_URL + "/v1/decide", {
232
+ const res = await guard.fetchBounded("/v1/decide", {
264
233
  method: "POST",
265
234
  headers: { "Content-Type": "application/json" },
266
235
  body: JSON.stringify({
@@ -270,7 +239,7 @@ async function callTool(name, args = {}) {
270
239
  }),
271
240
  });
272
241
  if (!res.ok) throw new Error(`POST /v1/decide failed: ${res.status}`);
273
- return res.json();
242
+ return JSON.parse(res.text);
274
243
  }
275
244
 
276
245
  if (name === "get_network_stats") return paidGet("/api/network/stats");
@@ -292,7 +261,7 @@ function textResult(value) {
292
261
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
293
262
  }
294
263
 
295
- const server = new McpServer({ name: "x402-ads-mcp", version: "0.2.4" });
264
+ const server = new McpServer({ name: "x402-ads-mcp", version: require("./package.json").version });
296
265
  server.server.onerror = (error) => {
297
266
  console.error(error instanceof Error ? error.message : String(error));
298
267
  };
@@ -344,7 +313,7 @@ async function registerCli(argv) {
344
313
  console.error("--accept-terms is required — read them first: " + BASE_URL + "/terms");
345
314
  process.exit(1);
346
315
  }
347
- const httpClient = walletClient();
316
+ const httpClient = walletClient(registerGuard);
348
317
  if (!httpClient) {
349
318
  console.error("WALLET_PRIVATE_KEY is required: a Base mainnet wallet holding at least $0.10 USDC.");
350
319
  process.exit(1);
@@ -353,24 +322,18 @@ async function registerCli(argv) {
353
322
  if (!args.contact) delete args.contact;
354
323
  if (!args.categories.length) delete args.categories;
355
324
 
356
- const url = BASE_URL + "/v1/publishers/register";
357
- const init = { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(args) };
358
- const res = await fetch(url, init);
359
- if (res.status === 400) {
360
- const body = await res.json().catch(() => ({}));
361
- console.error("Rejected before payment (nothing was charged): " + (body.error || res.statusText));
325
+ const path = "/v1/publishers/register";
326
+ const body = JSON.stringify(args);
327
+ // Pre-flight unpaid request so a rejected body is reported before any payment is attempted.
328
+ const pre = await registerGuard.fetchBounded(path, { method: "POST", headers: { "content-type": "application/json" }, body });
329
+ if (pre.status === 400) {
330
+ let msg = pre.text.slice(0, 200);
331
+ try { msg = JSON.parse(pre.text).error || msg; } catch (_) { /* keep raw text */ }
332
+ console.error("Rejected before payment (nothing was charged): " + msg);
362
333
  process.exit(1);
363
334
  }
364
- if (res.status !== 402) throw new Error(`expected x402 challenge, got ${res.status}`);
365
- let challengeBody;
366
- try {
367
- challengeBody = await res.clone().json();
368
- } catch (_) {}
369
- const paymentRequired = httpClient.getPaymentRequiredResponse((n) => res.headers.get(n), challengeBody);
370
- const paymentPayload = await createChainTimedPaymentPayload(httpClient, paymentRequired);
371
- const paidRes = await fetch(url, { ...init, headers: { ...init.headers, ...httpClient.encodePaymentSignatureHeader(paymentPayload) } });
372
- const out = await paidRes.json().catch(() => ({}));
373
- if (!paidRes.ok) throw new Error(`registration failed (${paidRes.status}): ${JSON.stringify(out).slice(0, 240)}`);
335
+ if (pre.status !== 402) throw new Error(`expected x402 challenge, got ${pre.status}`);
336
+ const { _payment, ...out } = await registerGuard.callPaid(httpClient, path, { method: "POST", body: args });
374
337
  console.log("Registered: " + out.publisher_id);
375
338
  console.log("");
376
339
  console.log("Your publisher key (shown once — store it now):");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgemeshlabs/x402-ads-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "mcpName": "io.github.forgemeshlabs/x402-ads-mcp",
5
5
  "description": "Install one middleware. Monetize unused 402 responses with recommendations, discovery, and machine commerce analytics. MCP server: 7 tools for agent demand analytics, paid per call in USDC on Base; free for publishers on their own traffic.",
6
6
  "main": "index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "index.js",
12
+ "x402-guard.js",
12
13
  "README.md",
13
14
  "LICENSE",
14
15
  "glama.json",
@@ -58,11 +59,11 @@
58
59
  "url": "https://github.com/forgemeshlabs/x402-ads-mcp/issues"
59
60
  },
60
61
  "dependencies": {
61
- "@modelcontextprotocol/sdk": "^1.30.0",
62
- "@x402/core": "2.11.0",
63
- "@x402/evm": "2.11.0",
62
+ "@modelcontextprotocol/sdk": "^1.32.1",
63
+ "@x402/core": "^2.29.0",
64
+ "@x402/evm": "^2.29.0",
64
65
  "viem": "^2.0.0",
65
- "zod": "^3.25.76"
66
+ "zod": "^4.6.7"
66
67
  },
67
68
  "overrides": {
68
69
  "ws": "8.21.0"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/forgemeshlabs/x402-ads-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.4",
9
+ "version": "0.2.6",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@forgemeshlabs/x402-ads-mcp",
14
- "version": "0.2.4",
14
+ "version": "0.2.6",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -29,20 +29,6 @@
29
29
  "isRequired": false,
30
30
  "isSecret": true,
31
31
  "format": "string"
32
- },
33
- {
34
- "name": "X402_ADS_BASE_URL",
35
- "description": "Optional intent network base URL. Defaults to https://ads.forgemesh.io.",
36
- "isRequired": false,
37
- "isSecret": false,
38
- "format": "string"
39
- },
40
- {
41
- "name": "BASE_RPC_URL",
42
- "description": "Optional Base mainnet RPC URL. Defaults to https://mainnet.base.org.",
43
- "isRequired": false,
44
- "isSecret": false,
45
- "format": "string"
46
32
  }
47
33
  ]
48
34
  }
package/x402-guard.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ /**
3
+ * x402-guard — client-side payment and HTTP guard for ForgeMesh MCP servers (vendored copy per repo).
4
+ *
5
+ * const { createGuard } = require("./x402-guard");
6
+ * const guard = createGuard({
7
+ * baseUrl: "https://x402.coinopai.com", // the ONLY origin this server talks to
8
+ * payTo: ["0x1304EC1A8945365e43A5c18a734065f107B417cA"], // wallets a 402 may name
9
+ * maxPriceUsd: 1.00, // per-call ceiling (env X402_MAX_PRICE_USD overrides, lower only)
10
+ * sessionBudgetUsd: 10.00, // cumulative ceiling per process (env X402_SESSION_BUDGET_USD)
11
+ * });
12
+ * const client = new x402Client().register("eip155:*", new ExactEvmScheme(signer)).registerPolicy(guard.policy);
13
+ * const data = await guard.callPaid(httpClient, "/api/route", { method: "POST", body: {...} });
14
+ *
15
+ * What it enforces, before any signature is produced:
16
+ * network = eip155:8453 (Base mainnet), asset = USDC on Base, payTo in the allowlist,
17
+ * amount <= maxPriceUsd, running total <= sessionBudgetUsd.
18
+ * What it enforces on every HTTP call: same origin as baseUrl, https, no redirects,
19
+ * REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES. No global monkeypatching anywhere.
20
+ */
21
+
22
+ const BASE_MAINNET = "eip155:8453";
23
+ const USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
24
+ const REQUEST_TIMEOUT_MS = 60_000;
25
+ const MAX_RESPONSE_BYTES = 2_000_000;
26
+
27
+ function usdFromRequirement(r) {
28
+ const raw = r.amount ?? r.maxAmountRequired ?? r.value;
29
+ if (raw === undefined || raw === null) return NaN;
30
+ const s = String(raw);
31
+ if (s.startsWith("$")) return Number(s.slice(1));
32
+ return Number(BigInt(s)) / 1e6; // USDC atomic units
33
+ }
34
+
35
+ function lowerCap(defaultUsd, envName) {
36
+ const env = Number(process.env[envName]);
37
+ return Number.isFinite(env) && env > 0 ? Math.min(env, defaultUsd) : defaultUsd;
38
+ }
39
+
40
+ function createGuard(opts) {
41
+ if (!opts || !opts.baseUrl) throw new Error("x402-guard: baseUrl is required");
42
+ const base = new URL(opts.baseUrl);
43
+ if (base.protocol !== "https:") throw new Error("x402-guard: baseUrl must be https");
44
+ const origin = base.origin;
45
+ // An empty allowlist means "this server never signs payments": the policy refuses every 402 and
46
+ // only fetchBounded is useful. Pass payTo: [] explicitly for free-only servers.
47
+ if (!Array.isArray(opts.payTo)) throw new Error("x402-guard: payTo allowlist is required (use [] for a server that never signs)");
48
+ const payTo = new Set(opts.payTo.map((a) => String(a).toLowerCase()));
49
+ const maxPriceUsd = lowerCap(opts.maxPriceUsd ?? 1.0, "X402_MAX_PRICE_USD");
50
+ const sessionBudgetUsd = lowerCap(opts.sessionBudgetUsd ?? 10.0, "X402_SESSION_BUDGET_USD");
51
+ let spentUsd = 0; // reserved at signing time, never released: a signed authorization can be settled even if our retry fails
52
+
53
+ function policy(_version, requirements) {
54
+ if (payTo.size === 0) throw new Error("x402-guard refused to sign: this server has no payee allowlist and never signs payments");
55
+ const kept = requirements.filter((r) => {
56
+ if (r.network !== BASE_MAINNET) return false;
57
+ if (String(r.asset || "").toLowerCase() !== USDC_BASE) return false;
58
+ if (!payTo.has(String(r.payTo || "").toLowerCase())) return false;
59
+ const usd = usdFromRequirement(r);
60
+ if (!Number.isFinite(usd) || usd <= 0 || usd > maxPriceUsd) return false;
61
+ if (spentUsd + usd > sessionBudgetUsd) return false;
62
+ return true;
63
+ });
64
+ if (kept.length === 0) {
65
+ const seen = requirements.map((r) => `${r.network} ${r.payTo} $${usdFromRequirement(r)}`).join("; ");
66
+ throw new Error(`x402-guard refused to sign: no payment option passed the guard (network must be ${BASE_MAINNET}, asset USDC, payTo in allowlist, price <= $${maxPriceUsd}, session total <= $${sessionBudgetUsd}). Offered: ${seen}`);
67
+ }
68
+ // Hand the selector exactly one option so the amount we reserve is the amount that gets signed.
69
+ const chosen = kept.reduce((a, b) => (usdFromRequirement(b) < usdFromRequirement(a) ? b : a));
70
+ spentUsd += usdFromRequirement(chosen);
71
+ return [chosen];
72
+ }
73
+
74
+ async function fetchBounded(url, init = {}) {
75
+ const target = new URL(String(url), origin);
76
+ if (target.origin !== origin) throw new Error(`x402-guard: refusing request to ${target.origin}; this server only talks to ${origin}`);
77
+ const ctrl = new AbortController();
78
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
79
+ try {
80
+ const res = await fetch(target.toString(), { ...init, signal: ctrl.signal, redirect: "error" });
81
+ const declared = Number(res.headers.get("content-length") || 0);
82
+ if (declared > MAX_RESPONSE_BYTES) { ctrl.abort(); throw new Error(`x402-guard: response too large (${declared} bytes)`); }
83
+ const chunks = [];
84
+ let received = 0;
85
+ if (res.body) {
86
+ const reader = res.body.getReader();
87
+ for (;;) {
88
+ const { done, value } = await reader.read();
89
+ if (done) break;
90
+ received += value.byteLength;
91
+ if (received > MAX_RESPONSE_BYTES) { ctrl.abort(); throw new Error("x402-guard: response too large"); }
92
+ chunks.push(value);
93
+ }
94
+ }
95
+ const bytes = Buffer.concat(chunks);
96
+ return { status: res.status, ok: res.ok, headers: res.headers, bytes, text: bytes.toString("utf8") };
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ function parseJson(res) {
103
+ try { return JSON.parse(res.text); } catch { throw new Error(`HTTP ${res.status}: non-JSON response`); }
104
+ }
105
+
106
+ // GET or POST a paid route: unpaid request → 402 → guarded signature → retry. Returns parsed JSON;
107
+ // on a paid response attaches the settlement receipt as `_payment` when the body is an object.
108
+ async function callPaid(httpClient, path, { method = "GET", body, query, headers = {} } = {}) {
109
+ const url = new URL(path, origin);
110
+ if (query) for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
111
+ const init = { method, headers: { ...headers, ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) };
112
+ const res = await fetchBounded(url, init);
113
+ if (res.status !== 402) {
114
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.text.slice(0, 200)}`);
115
+ return parseJson(res);
116
+ }
117
+ let challenge;
118
+ try { challenge = JSON.parse(res.text); } catch { challenge = undefined; }
119
+ const paymentRequired = httpClient.getPaymentRequiredResponse((n) => res.headers.get(n), challenge);
120
+ const payload = await httpClient.createPaymentPayload(paymentRequired); // policy runs (and reserves budget) inside
121
+ const paid = await fetchBounded(url, { ...init, headers: { ...init.headers, ...httpClient.encodePaymentSignatureHeader(payload) } });
122
+ if (!paid.ok) throw new Error(`Payment failed — HTTP ${paid.status}: ${paid.text.slice(0, 200)}`);
123
+ let settle = null;
124
+ try { settle = httpClient.getPaymentSettleResponse((n) => paid.headers.get(n)) || null; } catch { settle = null; }
125
+ const contentType = paid.headers.get("content-type") || "";
126
+ // Non-JSON paid responses (audio, images) come back as raw bytes, never decoded as text.
127
+ if (!contentType.includes("json")) return { _binary: true, content_type: contentType, bytes: paid.bytes, _payment: settle };
128
+ const data = parseJson(paid);
129
+ if (settle && data && typeof data === "object" && !Array.isArray(data)) return { ...data, _payment: settle };
130
+ return data;
131
+ }
132
+
133
+ return {
134
+ policy,
135
+ fetchBounded,
136
+ callPaid,
137
+ limits: () => ({ maxPriceUsd, sessionBudgetUsd, spentUsd, origin, payTo: [...payTo] }),
138
+ };
139
+ }
140
+
141
+ module.exports = { createGuard, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, BASE_MAINNET, USDC_BASE };