@bankofai/x402-cli 1.0.1-beta.1 → 1.0.1-beta.10

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/dist/daemon.js ADDED
@@ -0,0 +1,47 @@
1
+ import net from "node:net";
2
+ import { spawn } from "node:child_process";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { opt, outputMode } from "./args.js";
5
+ import { emit } from "./output.js";
6
+ function stripFlag(argv, flag) {
7
+ return argv.filter(item => item !== flag);
8
+ }
9
+ async function waitForPort(host, port, timeout = 5_000) {
10
+ const deadline = Date.now() + timeout;
11
+ while (Date.now() < deadline) {
12
+ const connected = await new Promise(resolve => {
13
+ const socket = net.createConnection({ host, port });
14
+ socket.setTimeout(250);
15
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
16
+ socket.once("error", () => resolve(false));
17
+ socket.once("timeout", () => { socket.destroy(); resolve(false); });
18
+ });
19
+ if (connected)
20
+ return;
21
+ await delay(50);
22
+ }
23
+ throw new Error(`daemon did not become ready on ${host}:${port}`);
24
+ }
25
+ export async function startServeDaemon(argv, options, requirement, script) {
26
+ if (!requirement.payTo)
27
+ throw new Error("--pay-to is required");
28
+ const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d");
29
+ const child = spawn(process.execPath, [script, ...daemonArgs], { detached: true, stdio: "ignore", env: process.env });
30
+ child.unref();
31
+ const host = opt(options, "host", "127.0.0.1");
32
+ const port = Number(opt(options, "port", "4020"));
33
+ const resourceUrl = opt(options, "resource-url", `http://${host}:${port}/pay`);
34
+ try {
35
+ await waitForPort(host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host, port);
36
+ }
37
+ catch (error) {
38
+ if (child.pid)
39
+ try {
40
+ process.kill(child.pid);
41
+ }
42
+ catch { /* child already exited */ }
43
+ throw error;
44
+ }
45
+ emit({ command: "server", mode: outputMode(options), network: requirement.network, scheme: requirement.scheme,
46
+ result: { pid: child.pid, pay_url: resourceUrl, resource_url: resourceUrl, daemon: true } });
47
+ }
@@ -0,0 +1,56 @@
1
+ import { paymentRequirements, priceUsd } from "./config.js";
2
+ function configs(input) {
3
+ return input instanceof Map ? [...input.values()].map(entry => entry.config) : [...input];
4
+ }
5
+ export function providerCatalogProjection(provider) {
6
+ return {
7
+ name: provider.name,
8
+ title: provider.title ?? provider.name,
9
+ description: "",
10
+ category: "other",
11
+ network: provider.operator.network,
12
+ currency: provider.operator.currencies?.usd?.[0] ?? "USDT",
13
+ endpoints: (provider.endpoints ?? []).map(endpoint => {
14
+ const price = priceUsd(endpoint);
15
+ const requirements = paymentRequirements(provider, price);
16
+ return {
17
+ method: endpoint.method.toUpperCase(),
18
+ path: `/providers/${provider.name}${endpoint.path}`,
19
+ upstream_path: endpoint.path,
20
+ description: "",
21
+ paid: price > 0 ? {
22
+ scheme: requirements[0]?.scheme,
23
+ network: requirements[0]?.network,
24
+ currency: provider.operator.currencies?.usd?.[0] ?? "USDT",
25
+ price_usd: price,
26
+ } : null,
27
+ x402_routes: requirements.map(requirement => ({
28
+ provider: provider.name,
29
+ network: requirement.network,
30
+ scheme: requirement.scheme,
31
+ ...(requirement.extra?.assetTransferMethod ? { assetTransferMethod: requirement.extra.assetTransferMethod } : {}),
32
+ url: `/providers/${provider.name}${endpoint.path}`,
33
+ })),
34
+ };
35
+ }),
36
+ };
37
+ }
38
+ export function buildGatewayCatalog(input) {
39
+ return { version: 1, generatedAt: new Date().toISOString(), providers: configs(input).map(providerCatalogProjection) };
40
+ }
41
+ export function providerPaymentAssets(input) {
42
+ return configs(input).flatMap(provider => (provider.endpoints ?? []).flatMap(endpoint => {
43
+ const price = priceUsd(endpoint);
44
+ const currencies = provider.operator.currencies?.usd ?? ["USDT"];
45
+ return paymentRequirements(provider, price).map((requirement, index) => ({
46
+ provider: provider.name,
47
+ method: endpoint.method,
48
+ path: `/providers/${provider.name}${endpoint.path}`,
49
+ network: requirement.network,
50
+ currency: currencies[index % currencies.length],
51
+ price_usd: price,
52
+ scheme: requirement.scheme,
53
+ ...(requirement.extra?.assetTransferMethod ? { assetTransferMethod: requirement.extra.assetTransferMethod } : {}),
54
+ }));
55
+ }));
56
+ }
@@ -30,6 +30,11 @@ function assertHttpUrl(value, name) {
30
30
  const url = new URL(value);
31
31
  if (!["http:", "https:"].includes(url.protocol))
32
32
  throw new Error("unsupported protocol");
33
+ if (url.username || url.password || url.search || url.hash)
34
+ throw new Error("credentials, query, and fragment are not allowed");
35
+ if (url.protocol === "http:" && !["localhost", "127.0.0.1", "::1"].includes(url.hostname) && process.env.X402_GATEWAY_ALLOW_INSECURE_HTTP !== "true") {
36
+ throw new Error("remote HTTP is not allowed");
37
+ }
33
38
  }
34
39
  catch {
35
40
  throw new Error(`${name} must be a valid http(s) URL`);
@@ -80,6 +85,13 @@ function validateProvider(config, file) {
80
85
  if (authMethod && !["header", "query_param", "access_token", "oauth2"].includes(authMethod)) {
81
86
  throw new Error(`${file}: unsupported routing.auth.method ${authMethod}`);
82
87
  }
88
+ const auth = config.routing?.auth;
89
+ if (auth) {
90
+ if (!auth.value && !auth.value_from_env)
91
+ throw new Error(`${file}: routing.auth requires value or value_from_env`);
92
+ if (auth.value_from_env && !process.env[auth.value_from_env])
93
+ throw new Error(`${file}: environment variable ${auth.value_from_env} is not set`);
94
+ }
83
95
  if (!config.endpoints?.length)
84
96
  throw new Error(`${file}: endpoints must contain at least one endpoint`);
85
97
  const seen = new Set();
@@ -104,25 +116,35 @@ export function loadProvider(file) {
104
116
  validateProvider(config, file);
105
117
  config.operator.network = normalizeNetwork(config.operator.network);
106
118
  normalizePaymentProtocol(config, file);
119
+ validatePaymentCapabilities(config, file);
120
+ const facilitatorUrl = config.operator.facilitator_url ||
121
+ process.env.X402_FACILITATOR_URL ||
122
+ process.env.FACILITATOR_URL ||
123
+ "https://facilitator.bankofai.io";
124
+ assertHttpUrl(facilitatorUrl, `${file}: facilitator URL`);
125
+ const configuredApiKeyEnv = config.operator.facilitator_api_key_env;
126
+ if (configuredApiKeyEnv && !process.env[configuredApiKeyEnv]) {
127
+ throw new Error(`${file}: environment variable ${configuredApiKeyEnv} is not set`);
128
+ }
107
129
  return {
108
130
  config,
109
- facilitatorUrl: config.operator.facilitator_url ||
110
- process.env.X402_FACILITATOR_URL ||
111
- process.env.FACILITATOR_URL ||
112
- "https://facilitator.bankofai.io",
131
+ facilitatorUrl,
113
132
  facilitatorApiKey: config.operator.facilitator_api_key ||
114
- (config.operator.facilitator_api_key_env
115
- ? process.env[config.operator.facilitator_api_key_env]
133
+ (configuredApiKeyEnv
134
+ ? process.env[configuredApiKeyEnv]
116
135
  : undefined) ||
117
136
  process.env.X402_FACILITATOR_API_KEY ||
118
137
  process.env.FACILITATOR_API_KEY,
119
138
  };
120
139
  }
121
140
  function normalizePaymentProtocol(config, file) {
122
- const rawSchemes = config.operator.schemes?.length
123
- ? config.operator.schemes
124
- : [config.operator.protocol || config.operator.scheme || "exact"];
141
+ if (config.operator.schemes !== undefined && (!Array.isArray(config.operator.schemes) || !config.operator.schemes.length)) {
142
+ throw new Error(`${file}: operator.schemes must be a non-empty string array`);
143
+ }
144
+ const rawSchemes = config.operator.schemes ?? [config.operator.protocol || config.operator.scheme || "exact"];
125
145
  const schemes = [...new Set(rawSchemes.map(value => {
146
+ if (typeof value !== "string" || !value.trim())
147
+ throw new Error(`${file}: operator.schemes must contain non-empty strings`);
126
148
  const raw = String(value).toLowerCase();
127
149
  const normalized = raw.replace(/[-:\s]/g, "_");
128
150
  if (!["exact", "exact_gasfree", "exact_permit", "permit2", "exact_permit2"].includes(normalized)) {
@@ -145,6 +167,33 @@ function normalizePaymentProtocol(config, file) {
145
167
  delete config.operator.assetTransferMethod;
146
168
  }
147
169
  }
170
+ function validatePaymentCapabilities(config, file) {
171
+ const symbols = config.operator.currencies?.usd ?? ["USDT"];
172
+ if (!Array.isArray(symbols) || !symbols.length || symbols.some(symbol => typeof symbol !== "string" || !symbol.trim())) {
173
+ throw new Error(`${file}: operator.currencies.usd must be a non-empty string array`);
174
+ }
175
+ if (new Set(symbols.map(symbol => symbol.toUpperCase())).size !== symbols.length)
176
+ throw new Error(`${file}: operator.currencies.usd must not contain duplicates`);
177
+ for (const symbol of symbols)
178
+ getToken(config.operator.network, symbol);
179
+ const recipient = config.recipients?.[config.operator.recipient]?.account ?? config.operator.recipient;
180
+ assertString(recipient, `${file}: resolved recipient`);
181
+ const validRecipient = config.operator.network.startsWith("tron:")
182
+ ? /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(recipient)
183
+ : /^0x[0-9a-fA-F]{40}$/.test(recipient);
184
+ if (!validRecipient)
185
+ throw new Error(`${file}: operator.recipient must be a valid address or a resolvable recipient alias`);
186
+ for (const endpoint of config.endpoints ?? []) {
187
+ if (!endpoint.metering)
188
+ continue;
189
+ const prices = [endpoint.metering.dimensions?.[0]?.tiers?.[0]?.price_usd,
190
+ ...(endpoint.metering.variants ?? []).map(variant => variant.dimensions?.[0]?.tiers?.[0]?.price_usd)]
191
+ .filter((price) => typeof price === "number" && price > 0);
192
+ for (const price of prices)
193
+ if (!paymentRequirements(config, price).length)
194
+ throw new Error(`${file}: paid endpoint cannot generate payment requirements`);
195
+ }
196
+ }
148
197
  export function loadProviders(providerPath) {
149
198
  const stat = fs.statSync(providerPath);
150
199
  const files = stat.isDirectory()
@@ -168,6 +217,8 @@ export function endpointFor(provider, method, routePath) {
168
217
  return provider.endpoints?.find(endpoint => endpoint.method.toUpperCase() === method.toUpperCase() && pathMatches(endpoint.path, routePath));
169
218
  }
170
219
  function pathMatches(template, routePath) {
220
+ if (!routePath.startsWith("/") || routePath.startsWith("//") || routePath.includes("\\") || routePath.includes("\0") || /%(?:2f|5c)/i.test(routePath))
221
+ return false;
171
222
  const templateParts = template.split("/").filter(Boolean);
172
223
  const routeParts = routePath.split("/").filter(Boolean);
173
224
  if (templateParts.length !== routeParts.length)
@@ -0,0 +1,3 @@
1
+ export { endpointFor, loadProvider, loadProviders, paymentRequirements, priceUsd, } from "./config.js";
2
+ export { getToken, normalizeNetwork, toSmallestUnit, TOKENS, } from "./tokens.js";
3
+ export { buildGatewayCatalog, providerCatalogProjection, providerPaymentAssets } from "./catalog.js";
@@ -1,14 +1,17 @@
1
1
  import http from "node:http";
2
+ import { timingSafeEqual } from "node:crypto";
2
3
  import { URL } from "node:url";
3
4
  import { endpointFor, paymentRequirements, priceUsd } from "./config.js";
4
5
  import { decodeSignature, encodeRequired, encodeResponse, headers, matchRequirement } from "./x402.js";
5
6
  class HttpError extends Error {
6
7
  status;
7
8
  publicMessage;
8
- constructor(status, publicMessage, message = publicMessage) {
9
+ responseHeaders;
10
+ constructor(status, publicMessage, message = publicMessage, responseHeaders = {}) {
9
11
  super(message);
10
12
  this.status = status;
11
13
  this.publicMessage = publicMessage;
14
+ this.responseHeaders = responseHeaders;
12
15
  }
13
16
  }
14
17
  class RequestTooLargeError extends HttpError {
@@ -16,9 +19,23 @@ class RequestTooLargeError extends HttpError {
16
19
  super(413, "request body too large");
17
20
  }
18
21
  }
19
- const MAX_BODY_BYTES = Number(process.env.X402_GATEWAY_MAX_BODY_BYTES ?? 1_000_000);
20
- const FACILITATOR_TIMEOUT_MS = Number(process.env.X402_GATEWAY_FACILITATOR_TIMEOUT_MS ?? 10_000);
21
- const UPSTREAM_TIMEOUT_MS = Number(process.env.X402_GATEWAY_UPSTREAM_TIMEOUT_MS ?? 30_000);
22
+ function positiveIntegerEnv(name, fallback) {
23
+ const raw = process.env[name];
24
+ if (raw === undefined || raw === "")
25
+ return fallback;
26
+ if (!/^\d+$/.test(raw))
27
+ throw new Error(`${name} must be a positive integer`);
28
+ const value = Number(raw);
29
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647)
30
+ throw new Error(`${name} must be an integer between 1 and 2147483647`);
31
+ return value;
32
+ }
33
+ const MAX_BODY_BYTES = positiveIntegerEnv("X402_GATEWAY_MAX_BODY_BYTES", 1_000_000);
34
+ const FACILITATOR_TIMEOUT_MS = positiveIntegerEnv("X402_GATEWAY_FACILITATOR_TIMEOUT_MS", 10_000);
35
+ const UPSTREAM_TIMEOUT_MS = positiveIntegerEnv("X402_GATEWAY_UPSTREAM_TIMEOUT_MS", 30_000);
36
+ const MAX_RESPONSE_BYTES = positiveIntegerEnv("X402_GATEWAY_MAX_RESPONSE_BYTES", 10_000_000);
37
+ const MAX_CONCURRENT_REQUESTS = positiveIntegerEnv("X402_GATEWAY_MAX_CONCURRENT_REQUESTS", 100);
38
+ const RATE_LIMIT_PER_MINUTE = positiveIntegerEnv("X402_GATEWAY_RATE_LIMIT_PER_MINUTE", 300);
22
39
  const STRIP_REQUEST_HEADERS = new Set([
23
40
  "host",
24
41
  "connection",
@@ -36,6 +53,8 @@ const STRIP_REQUEST_HEADERS = new Set([
36
53
  "payment-signature",
37
54
  "payment-required",
38
55
  "x-payment-required",
56
+ "payment-response",
57
+ "x-payment-response",
39
58
  "accept-encoding",
40
59
  ]);
41
60
  const STRIP_RESPONSE_HEADERS = new Set([
@@ -46,14 +65,23 @@ const STRIP_RESPONSE_HEADERS = new Set([
46
65
  "authorization",
47
66
  "proxy-authorization",
48
67
  "set-cookie",
68
+ "payment-required",
69
+ "x-payment-required",
70
+ "payment-signature",
71
+ "x-payment",
72
+ "payment-response",
73
+ "x-payment-response",
49
74
  ]);
50
- const metrics = {
51
- requests: 0,
52
- paidRequests: 0,
53
- verifyFailures: 0,
54
- settleFailures: 0,
55
- upstreamFailures: 0,
56
- };
75
+ function createMetrics() {
76
+ return {
77
+ requests: 0,
78
+ paidRequests: 0,
79
+ verifyFailures: 0,
80
+ settleFailures: 0,
81
+ upstreamFailures: 0,
82
+ rejectedRequests: 0,
83
+ };
84
+ }
57
85
  async function readBody(request) {
58
86
  const chunks = [];
59
87
  let total = 0;
@@ -67,17 +95,20 @@ async function readBody(request) {
67
95
  return Buffer.concat(chunks);
68
96
  }
69
97
  function json(response, status, body, extraHeaders = {}) {
70
- response.writeHead(status, { "content-type": "application/json", ...extraHeaders });
98
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff", ...extraHeaders });
71
99
  response.end(JSON.stringify(body));
72
100
  }
73
101
  async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
74
102
  const controller = new AbortController();
75
103
  const timer = setTimeout(() => controller.abort(), timeoutMs);
76
104
  try {
77
- return await fetch(url, { ...init, signal: controller.signal });
105
+ const response = await fetch(url, { ...init, redirect: "manual", signal: controller.signal });
106
+ if (response.status >= 300 && response.status < 400)
107
+ throw new HttpError(502, `${label} redirect refused`);
108
+ return response;
78
109
  }
79
110
  catch (error) {
80
- if (error?.name === "AbortError")
111
+ if (error instanceof Error && error.name === "AbortError")
81
112
  throw new HttpError(504, `${label} request timed out`);
82
113
  throw error;
83
114
  }
@@ -85,13 +116,30 @@ async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
85
116
  clearTimeout(timer);
86
117
  }
87
118
  }
119
+ async function readResponseBytes(response, limit = MAX_RESPONSE_BYTES) {
120
+ const declared = Number(response.headers.get("content-length"));
121
+ if (Number.isFinite(declared) && declared > limit)
122
+ throw new HttpError(502, "upstream response too large");
123
+ if (!response.body)
124
+ return Buffer.alloc(0);
125
+ const chunks = [];
126
+ let total = 0;
127
+ for await (const chunk of response.body) {
128
+ const buffer = Buffer.from(chunk);
129
+ total += buffer.length;
130
+ if (total > limit)
131
+ throw new HttpError(502, "upstream response too large");
132
+ chunks.push(buffer);
133
+ }
134
+ return Buffer.concat(chunks);
135
+ }
88
136
  async function facilitatorPost(entry, path, body) {
89
137
  const headers = { "content-type": "application/json" };
90
138
  if (entry.facilitatorApiKey) {
91
139
  headers["x-api-key"] = entry.facilitatorApiKey;
92
140
  }
93
- const response = await fetchWithTimeout(new URL(path, entry.facilitatorUrl), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator");
94
- const text = await response.text();
141
+ const response = await fetchWithTimeout(new URL(path.replace(/^\/+/, ""), `${entry.facilitatorUrl.replace(/\/+$/, "")}/`), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator");
142
+ const text = (await readResponseBytes(response, Math.min(MAX_RESPONSE_BYTES, 1_000_000))).toString("utf8");
95
143
  let data = {};
96
144
  try {
97
145
  data = text ? JSON.parse(text) : {};
@@ -102,10 +150,34 @@ async function facilitatorPost(entry, path, body) {
102
150
  }
103
151
  if (!response.ok) {
104
152
  logFacilitatorFailure(entry, path, response, body, data);
153
+ if (response.status === 429) {
154
+ const retryAfter = response.headers.get("retry-after");
155
+ throw new HttpError(429, "facilitator rate limited", `facilitator ${path} rate limited`, retryAfter ? { "retry-after": retryAfter } : {});
156
+ }
105
157
  throw new HttpError(502, "facilitator request failed", `facilitator ${path} failed: ${response.status}`);
106
158
  }
107
159
  return data;
108
160
  }
161
+ function configuredPublicBaseUrl() {
162
+ const value = process.env.X402_GATEWAY_PUBLIC_BASE_URL?.trim();
163
+ if (!value)
164
+ return undefined;
165
+ try {
166
+ const url = new URL(value);
167
+ if (!["http:", "https:"].includes(url.protocol))
168
+ throw new Error("unsupported protocol");
169
+ return `${value.replace(/\/+$/, "")}/`;
170
+ }
171
+ catch {
172
+ throw new Error("X402_GATEWAY_PUBLIC_BASE_URL must be a valid http(s) URL");
173
+ }
174
+ }
175
+ function resourceUrl(url, publicBaseUrl) {
176
+ const path = `${url.pathname}${url.search}`;
177
+ if (!publicBaseUrl)
178
+ return path;
179
+ return new URL(path, publicBaseUrl).toString();
180
+ }
109
181
  function logFacilitatorFailure(entry, path, response, body, data) {
110
182
  const requirement = body?.paymentRequirements;
111
183
  const nestedError = data?.error && typeof data.error === "object" ? data.error : undefined;
@@ -128,17 +200,19 @@ function isVerifySuccess(verify) {
128
200
  return verify?.valid === true || verify?.isValid === true;
129
201
  }
130
202
  function isSettleSuccess(settle) {
131
- return (settle?.success === true ||
132
- settle?.settled === true ||
133
- (typeof settle?.transaction === "string" && settle.transaction.length > 0) ||
134
- (typeof settle?.txHash === "string" && settle.txHash.length > 0));
203
+ return settle?.success === true && typeof settle?.transaction === "string" && settle.transaction.length > 0 && typeof settle?.network === "string" && settle.network.length > 0;
135
204
  }
136
205
  function isAdminAllowed(request) {
137
206
  const token = process.env.X402_GATEWAY_ADMIN_TOKEN;
138
207
  if (!token)
139
208
  return process.env.X402_GATEWAY_ADMIN_ALLOW_PUBLIC === "true";
140
209
  const auth = request.headers.authorization ?? "";
141
- return auth === `Bearer ${token}`;
210
+ const expected = Buffer.from(`Bearer ${token}`);
211
+ const actual = Buffer.from(auth);
212
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
213
+ }
214
+ function clientAddress(request) {
215
+ return request.socket.remoteAddress ?? "unknown";
142
216
  }
143
217
  function requestParams(url, body, request) {
144
218
  const params = {};
@@ -165,11 +239,12 @@ function requestParams(url, body, request) {
165
239
  }
166
240
  function upstreamHeaders(request, entry) {
167
241
  const headersOut = new Headers();
242
+ const connectionHeaders = new Set(String(request.headers.connection ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
168
243
  for (const [key, value] of Object.entries(request.headers)) {
169
244
  if (!value)
170
245
  continue;
171
246
  const lower = key.toLowerCase();
172
- if (STRIP_REQUEST_HEADERS.has(lower))
247
+ if (STRIP_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower))
173
248
  continue;
174
249
  headersOut.set(key, Array.isArray(value) ? value.join(",") : value);
175
250
  }
@@ -184,7 +259,14 @@ function upstreamHeaders(request, entry) {
184
259
  }
185
260
  function upstreamUrl(entry, request, routePath) {
186
261
  const sourceUrl = new URL(request.url ?? "/", "http://local");
187
- const upstream = new URL(routePath + (sourceUrl.search || ""), entry.config.forward_url);
262
+ if (!routePath.startsWith("/") || routePath.startsWith("//") || routePath.includes("\\") || routePath.includes("\0") || /%(?:2f|5c)/i.test(routePath))
263
+ throw new HttpError(400, "invalid provider path");
264
+ const base = new URL(entry.config.forward_url);
265
+ const upstream = new URL(base);
266
+ upstream.pathname = routePath;
267
+ upstream.search = sourceUrl.search;
268
+ if (upstream.origin !== base.origin)
269
+ throw new HttpError(400, "invalid provider path");
188
270
  const auth = entry.config.routing?.auth;
189
271
  const value = auth?.value ?? (auth?.value_from_env ? process.env[auth.value_from_env] : undefined);
190
272
  if (auth?.method === "query_param" && value) {
@@ -192,7 +274,7 @@ function upstreamUrl(entry, request, routePath) {
192
274
  }
193
275
  return upstream;
194
276
  }
195
- async function forward(entry, request, response, routePath, body, paymentResponse) {
277
+ async function forward(metrics, entry, request, response, routePath, body, paymentResponse) {
196
278
  const upstream = upstreamUrl(entry, request, routePath);
197
279
  let upstreamResponse;
198
280
  try {
@@ -216,11 +298,24 @@ async function forward(entry, request, response, routePath, body, paymentRespons
216
298
  });
217
299
  if (paymentResponse)
218
300
  responseHeaders[headers.response] = encodeResponse(paymentResponse);
301
+ let responseBody;
302
+ try {
303
+ responseBody = await readResponseBytes(upstreamResponse);
304
+ }
305
+ catch (error) {
306
+ metrics.upstreamFailures += 1;
307
+ throw error;
308
+ }
219
309
  response.writeHead(upstreamResponse.status, responseHeaders);
220
- response.end(Buffer.from(await upstreamResponse.arrayBuffer()));
310
+ response.end(responseBody);
221
311
  }
222
312
  export function createGatewayServer(providers) {
223
- return http.createServer(async (request, response) => {
313
+ const publicBaseUrl = configuredPublicBaseUrl();
314
+ const metrics = createMetrics();
315
+ const rateLimits = new Map();
316
+ let activeRequests = 0;
317
+ const server = http.createServer(async (request, response) => {
318
+ let countedActive = false;
224
319
  try {
225
320
  metrics.requests += 1;
226
321
  const url = new URL(request.url ?? "/", "http://local");
@@ -266,6 +361,7 @@ export function createGatewayServer(providers) {
266
361
  `x402_gateway_verify_failures_total ${metrics.verifyFailures}`,
267
362
  `x402_gateway_settle_failures_total ${metrics.settleFailures}`,
268
363
  `x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`,
364
+ `x402_gateway_rejected_requests_total ${metrics.rejectedRequests}`,
269
365
  "",
270
366
  ].join("\n"));
271
367
  return;
@@ -286,10 +382,32 @@ export function createGatewayServer(providers) {
286
382
  json(response, 404, { error: "endpoint not found" });
287
383
  return;
288
384
  }
385
+ const now = Date.now();
386
+ const address = clientAddress(request);
387
+ let rate = rateLimits.get(address);
388
+ if (!rate || rate.resetAt <= now) {
389
+ rate = { count: 0, resetAt: now + 60_000 };
390
+ rateLimits.set(address, rate);
391
+ }
392
+ rate.count += 1;
393
+ if (rate.count > RATE_LIMIT_PER_MINUTE) {
394
+ metrics.rejectedRequests += 1;
395
+ const retryAfter = Math.max(1, Math.ceil((rate.resetAt - now) / 1000));
396
+ throw new HttpError(429, "gateway rate limited", undefined, { "retry-after": String(retryAfter) });
397
+ }
398
+ if (activeRequests >= MAX_CONCURRENT_REQUESTS) {
399
+ metrics.rejectedRequests += 1;
400
+ throw new HttpError(503, "gateway is busy", undefined, { "retry-after": "1" });
401
+ }
402
+ activeRequests += 1;
403
+ countedActive = true;
289
404
  const body = await readBody(request);
290
- const requirements = paymentRequirements(entry.config, priceUsd(endpoint, requestParams(url, body, request)));
405
+ const price = priceUsd(endpoint, requestParams(url, body, request));
406
+ const requirements = paymentRequirements(entry.config, price);
407
+ if (price > 0 && !requirements.length)
408
+ throw new HttpError(500, "paid endpoint has no payment requirements");
291
409
  if (!requirements.length) {
292
- await forward(entry, request, response, routePath, body);
410
+ await forward(metrics, entry, request, response, routePath, body);
293
411
  return;
294
412
  }
295
413
  const paymentHeader = request.headers[headers.signature.toLowerCase()];
@@ -298,7 +416,7 @@ export function createGatewayServer(providers) {
298
416
  const challenge = {
299
417
  x402Version: 2,
300
418
  error: "Payment required",
301
- resource: { url: url.pathname },
419
+ resource: { url: resourceUrl(url, publicBaseUrl) },
302
420
  accepts,
303
421
  };
304
422
  json(response, 402, challenge, { [headers.required]: encodeRequired(challenge) });
@@ -343,15 +461,37 @@ export function createGatewayServer(providers) {
343
461
  return;
344
462
  }
345
463
  metrics.paidRequests += 1;
346
- await forward(entry, request, response, routePath, body, settle);
464
+ try {
465
+ await forward(metrics, entry, request, response, routePath, body, settle);
466
+ }
467
+ catch (error) {
468
+ const status = error instanceof HttpError ? error.status : 502;
469
+ const extraHeaders = error instanceof HttpError ? error.responseHeaders : {};
470
+ json(response, status, {
471
+ error: "upstream failed after payment settlement",
472
+ settled: true,
473
+ }, {
474
+ ...extraHeaders,
475
+ [headers.response]: encodeResponse(settle),
476
+ });
477
+ }
347
478
  }
348
479
  catch (error) {
349
480
  if (error instanceof HttpError) {
350
- json(response, error.status, { error: error.publicMessage });
481
+ json(response, error.status, { error: error.publicMessage }, error.responseHeaders);
351
482
  return;
352
483
  }
353
484
  console.error(error);
354
485
  json(response, 500, { error: "internal server error" });
355
486
  }
487
+ finally {
488
+ if (countedActive)
489
+ activeRequests -= 1;
490
+ }
356
491
  });
492
+ server.requestTimeout = UPSTREAM_TIMEOUT_MS + FACILITATOR_TIMEOUT_MS * 2 + 5_000;
493
+ server.headersTimeout = Math.min(server.requestTimeout, 60_000);
494
+ server.keepAliveTimeout = 5_000;
495
+ server.maxConnections = MAX_CONCURRENT_REQUESTS * 2;
496
+ return server;
357
497
  }
@@ -16,19 +16,25 @@ export const TOKENS = {
16
16
  },
17
17
  };
18
18
  export function normalizeNetwork(network) {
19
- return ({
19
+ const legacyTronIds = {
20
20
  "tron-mainnet": "tron:0x2b6653dc",
21
21
  "tron:mainnet": "tron:0x2b6653dc",
22
- "mainnet": "tron:0x2b6653dc",
22
+ mainnet: "tron:0x2b6653dc",
23
23
  "tron-shasta": "tron:0x94a9059e",
24
24
  "tron:shasta": "tron:0x94a9059e",
25
- "shasta": "tron:0x94a9059e",
25
+ shasta: "tron:0x94a9059e",
26
26
  "tron-nile": "tron:0xcd8690dc",
27
27
  "tron:nile": "tron:0xcd8690dc",
28
- "nile": "tron:0xcd8690dc",
28
+ nile: "tron:0xcd8690dc",
29
+ };
30
+ const canonical = legacyTronIds[network];
31
+ if (canonical) {
32
+ throw new Error(`legacy TRON network identifier ${network} is not supported; use ${canonical}`);
33
+ }
34
+ return {
29
35
  "bsc-mainnet": "eip155:56",
30
36
  "bsc-testnet": "eip155:97",
31
- }[network] ?? network);
37
+ }[network] ?? network;
32
38
  }
33
39
  export function getToken(network, symbol) {
34
40
  const normalized = normalizeNetwork(network);