@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 +2 -3
- package/README.md +3 -3
- package/glama.json +2 -1
- package/index.js +42 -79
- package/package.json +6 -5
- package/server.json +2 -16
- package/x402-guard.js +141 -0
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
|
-
| `
|
|
64
|
-
| `
|
|
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
|
-
|
|
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
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 {
|
|
12
|
-
const { base } = require("viem/chains");
|
|
11
|
+
const { createGuard } = require("./x402-guard");
|
|
13
12
|
|
|
14
|
-
const BASE_URL =
|
|
15
|
-
const
|
|
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
|
|
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:
|
|
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
|
|
227
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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: "
|
|
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
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 (
|
|
365
|
-
|
|
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.
|
|
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.
|
|
62
|
-
"@x402/core": "2.
|
|
63
|
-
"@x402/evm": "2.
|
|
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": "^
|
|
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.
|
|
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.
|
|
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 };
|