@bankofai/x402-cli 1.0.1-beta.7 → 1.0.1-beta.8

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/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import http from "node:http";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
+ import net from "node:net";
4
5
  import fs from "node:fs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
@@ -11,6 +12,7 @@ import YAML from "yaml";
11
12
  import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
12
13
  import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
13
14
  const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version"]);
15
+ const MAX_HTTP_BODY_BYTES = 10 * 1024 * 1024;
14
16
  const require = createRequire(import.meta.url);
15
17
  const DEFAULT_TIMEOUT_MS = 30_000;
16
18
  const CATALOG_UPDATE_RETRIES = 3;
@@ -71,7 +73,7 @@ function parseArgs(argv) {
71
73
  options[key] = true;
72
74
  }
73
75
  else if (!next || next.startsWith("--")) {
74
- options[key] = true;
76
+ throw new CliError("MISSING_ARGUMENT", `--${key} requires a value`, `Pass --${key} <value>.`, 2);
75
77
  }
76
78
  else {
77
79
  if (key === "header") {
@@ -653,7 +655,36 @@ async function readText(source, options) {
653
655
  const response = await fetchWithTimeout(source, {}, timeoutMs(options), `fetch ${source}`);
654
656
  if (!response.ok)
655
657
  throw new Error(`failed to fetch ${source}: ${response.status}`);
656
- return response.text();
658
+ return readBoundedText(response, `response from ${source}`);
659
+ }
660
+ async function readBoundedText(response, label) {
661
+ const declared = Number(response.headers.get("content-length"));
662
+ if (Number.isFinite(declared) && declared > MAX_HTTP_BODY_BYTES) {
663
+ throw new Error(`${label} exceeds ${MAX_HTTP_BODY_BYTES} byte limit`);
664
+ }
665
+ if (!response.body)
666
+ return "";
667
+ const reader = response.body.getReader();
668
+ const chunks = [];
669
+ let size = 0;
670
+ for (;;) {
671
+ const { done, value } = await reader.read();
672
+ if (done)
673
+ break;
674
+ size += value.byteLength;
675
+ if (size > MAX_HTTP_BODY_BYTES) {
676
+ await reader.cancel();
677
+ throw new Error(`${label} exceeds ${MAX_HTTP_BODY_BYTES} byte limit`);
678
+ }
679
+ chunks.push(value);
680
+ }
681
+ const bytes = new Uint8Array(size);
682
+ let offset = 0;
683
+ for (const chunk of chunks) {
684
+ bytes.set(chunk, offset);
685
+ offset += chunk.byteLength;
686
+ }
687
+ return new TextDecoder().decode(bytes);
657
688
  }
658
689
  async function readJson(source, options) {
659
690
  return JSON.parse(await readText(source, options));
@@ -663,7 +694,7 @@ function writeJson(file, value) {
663
694
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
664
695
  }
665
696
  async function responsePayload(response) {
666
- const text = await response.text();
697
+ const text = await readBoundedText(response, "HTTP response");
667
698
  const contentType = response.headers.get("content-type") ?? "";
668
699
  if (contentType.toLowerCase().includes("json")) {
669
700
  try {
@@ -696,7 +727,7 @@ function cachedCatalogPath() {
696
727
  return path.join(cacheDir(), "catalog.json");
697
728
  }
698
729
  function providerFilename(fqn) {
699
- return `${fqn.replace(/\//g, "__")}.json`;
730
+ return `${sanitizeProviderName(fqn).replace(/\//g, "__")}.json`;
700
731
  }
701
732
  function sanitizeProviderName(name) {
702
733
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(name) || name.includes("..")) {
@@ -758,8 +789,11 @@ function remoteBaseFromCatalogPayload(payload) {
758
789
  }
759
790
  async function remoteBaseFromSource(source, payload, options) {
760
791
  const fromPayload = payload ? remoteBaseFromCatalogPayload(payload) : undefined;
761
- if (fromPayload)
792
+ if (fromPayload) {
793
+ if (/^https?:\/\//.test(source) && new URL(fromPayload).origin !== new URL(source).origin)
794
+ throw new Error("catalog base_url must use the same origin as the catalog source");
762
795
  return fromPayload;
796
+ }
763
797
  if (source.startsWith("http://") || source.startsWith("https://")) {
764
798
  return `${source.slice(0, source.lastIndexOf("/") + 1)}`;
765
799
  }
@@ -771,6 +805,7 @@ async function remoteBaseFromSource(source, payload, options) {
771
805
  }
772
806
  }
773
807
  function catalogDetailSource(source, section, name) {
808
+ name = sanitizeProviderName(name);
774
809
  if (source.startsWith("http://") || source.startsWith("https://")) {
775
810
  const base = new URL(source);
776
811
  const pathname = base.pathname.endsWith("/catalog.json")
@@ -994,8 +1029,8 @@ function buildRequirement(options) {
994
1029
  if (!explicitAsset && !registryToken)
995
1030
  throw new Error(`unknown token ${tokenSymbol} on ${network}`);
996
1031
  const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken.decimals;
997
- if (!Number.isInteger(decimals) || decimals < 0)
998
- throw new Error("--decimals must be a non-negative integer");
1032
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255)
1033
+ throw new Error("--decimals must be an integer between 0 and 255");
999
1034
  const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount");
1000
1035
  const humanAmount = opt(options, "amount");
1001
1036
  if (rawAmount && humanAmount)
@@ -1003,13 +1038,17 @@ function buildRequirement(options) {
1003
1038
  const amount = rawAmount ? assertRawAmount(rawAmount, "--raw-amount") : toSmallestUnit(humanAmount ?? "0.0001", decimals);
1004
1039
  const assetAddress = explicitAsset ?? registryToken.address;
1005
1040
  const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2";
1041
+ const maxTimeoutSeconds = Number(opt(options, "valid-for-seconds", "300"));
1042
+ if (!Number.isInteger(maxTimeoutSeconds) || maxTimeoutSeconds <= 0 || maxTimeoutSeconds > 86400) {
1043
+ throw new Error("--valid-for-seconds must be an integer between 1 and 86400");
1044
+ }
1006
1045
  return {
1007
1046
  scheme,
1008
1047
  network,
1009
1048
  amount,
1010
1049
  asset: assetAddress,
1011
1050
  payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "",
1012
- maxTimeoutSeconds: Number(opt(options, "valid-for-seconds", "300")),
1051
+ maxTimeoutSeconds,
1013
1052
  extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {},
1014
1053
  };
1015
1054
  }
@@ -1019,10 +1058,16 @@ async function facilitatorPost(baseUrl, path, body, options) {
1019
1058
  headers: { "content-type": "application/json" },
1020
1059
  body: JSON.stringify(body),
1021
1060
  }, timeoutMs(options), `facilitator ${path}`);
1022
- const text = await response.text();
1023
- const data = text ? JSON.parse(text) : {};
1061
+ const text = await readBoundedText(response, `facilitator ${path} response`);
1062
+ let data = {};
1063
+ try {
1064
+ data = text ? JSON.parse(text) : {};
1065
+ }
1066
+ catch {
1067
+ throw new Error(`facilitator ${path} returned invalid JSON`);
1068
+ }
1024
1069
  if (!response.ok)
1025
- throw new Error(`facilitator ${path} failed: ${response.status} ${text}`);
1070
+ throw new Error(`facilitator ${path} failed with HTTP ${response.status}`);
1026
1071
  return data;
1027
1072
  }
1028
1073
  function requestHeaders(options) {
@@ -1038,7 +1083,23 @@ function requestHeaders(options) {
1038
1083
  function stripFlag(argv, flag) {
1039
1084
  return argv.filter(item => item !== flag);
1040
1085
  }
1041
- function serveDaemon(argv, options) {
1086
+ async function waitForPort(host, port, timeout = 5_000) {
1087
+ const deadline = Date.now() + timeout;
1088
+ while (Date.now() < deadline) {
1089
+ const connected = await new Promise(resolve => {
1090
+ const socket = net.createConnection({ host, port });
1091
+ socket.setTimeout(250);
1092
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
1093
+ socket.once("error", () => resolve(false));
1094
+ socket.once("timeout", () => { socket.destroy(); resolve(false); });
1095
+ });
1096
+ if (connected)
1097
+ return;
1098
+ await delay(50);
1099
+ }
1100
+ throw new Error(`daemon did not become ready on ${host}:${port}`);
1101
+ }
1102
+ async function serveDaemon(argv, options) {
1042
1103
  const requirement = buildRequirement(options);
1043
1104
  if (!requirement.payTo)
1044
1105
  throw new Error("--pay-to is required");
@@ -1053,6 +1114,18 @@ function serveDaemon(argv, options) {
1053
1114
  const host = opt(options, "host", "127.0.0.1");
1054
1115
  const port = Number(opt(options, "port", "4020"));
1055
1116
  const resourceUrl = opt(options, "resource-url", `http://${host}:${port}/pay`);
1117
+ try {
1118
+ await waitForPort(host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host, port);
1119
+ }
1120
+ catch (error) {
1121
+ if (child.pid) {
1122
+ try {
1123
+ process.kill(child.pid);
1124
+ }
1125
+ catch { /* child already exited */ }
1126
+ }
1127
+ throw error;
1128
+ }
1056
1129
  emit({
1057
1130
  command: "server",
1058
1131
  mode: outputMode(options),
@@ -1079,8 +1152,8 @@ function validateAmountLimits(selected, options) {
1079
1152
  throw new Error("cannot evaluate --max-amount for an unknown asset; pass --max-raw-amount or --decimals");
1080
1153
  }
1081
1154
  const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token.decimals;
1082
- if (!Number.isInteger(decimals) || decimals < 0)
1083
- throw new Error("--decimals must be a non-negative integer");
1155
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255)
1156
+ throw new Error("--decimals must be an integer between 0 and 255");
1084
1157
  if (BigInt(selected.amount) > BigInt(toSmallestUnit(maxAmount, decimals))) {
1085
1158
  throw new Error(`payment amount exceeds --max-amount ${maxAmount}`);
1086
1159
  }
@@ -1107,8 +1180,8 @@ function gasfreeFeeLimitRaw(selected, options) {
1107
1180
  throw new Error("cannot evaluate --max-gasfree-fee for an unknown asset; pass --max-gasfree-fee-raw or --decimals");
1108
1181
  }
1109
1182
  const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token.decimals;
1110
- if (!Number.isInteger(decimals) || decimals < 0)
1111
- throw new Error("--decimals must be a non-negative integer");
1183
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255)
1184
+ throw new Error("--decimals must be an integer between 0 and 255");
1112
1185
  return toSmallestUnit(maxHuman, decimals);
1113
1186
  }
1114
1187
  async function serve(options) {
@@ -1173,7 +1246,7 @@ async function serve(options) {
1173
1246
  paymentPayload: payload,
1174
1247
  paymentRequirements: requirement,
1175
1248
  }, options);
1176
- if (!(settle?.success === true || settle?.settled === true || typeof settle?.transaction === "string" || typeof settle?.txHash === "string")) {
1249
+ if (!(settle?.success === true && typeof settle?.transaction === "string" && settle.transaction.length > 0 && typeof settle?.network === "string" && settle.network.length > 0)) {
1177
1250
  response.writeHead(502, { "content-type": "application/json" });
1178
1251
  response.end(JSON.stringify({ error: "settlement failed" }));
1179
1252
  return;
@@ -1182,7 +1255,7 @@ async function serve(options) {
1182
1255
  "content-type": "application/json",
1183
1256
  [headers.response]: encodeResponse(settle),
1184
1257
  });
1185
- response.end(JSON.stringify({ success: true, network: requirement.network, scheme: requirement.scheme, transaction: settle.transaction ?? settle.txHash ?? null }));
1258
+ response.end(JSON.stringify({ success: true, network: requirement.network, scheme: requirement.scheme, transaction: settle.transaction }));
1186
1259
  }
1187
1260
  catch (error) {
1188
1261
  response.writeHead(500, { "content-type": "application/json" });
@@ -1209,6 +1282,17 @@ function selectRequirement(accepts, options) {
1209
1282
  const scheme = opt(options, "scheme");
1210
1283
  const token = opt(options, "token");
1211
1284
  const selected = accepts.find(req => {
1285
+ if (!req || !["exact", "exact_gasfree"].includes(req.scheme) || typeof req.network !== "string" || typeof req.asset !== "string")
1286
+ return false;
1287
+ if (req.scheme === "exact_gasfree" && !req.network.startsWith("tron:"))
1288
+ return false;
1289
+ try {
1290
+ if (!findTokenByAddress(req.network, req.asset))
1291
+ return false;
1292
+ }
1293
+ catch {
1294
+ return false;
1295
+ }
1212
1296
  if (network && normalizeNetwork(network) !== req.network)
1213
1297
  return false;
1214
1298
  if (scheme && scheme !== req.scheme)
@@ -1233,6 +1317,9 @@ function selectRequirement(accepts, options) {
1233
1317
  async function pay(url, options) {
1234
1318
  requireArgument(url, "URL", "x402-cli pay <url> [options]");
1235
1319
  const method = opt(options, "method", "GET");
1320
+ if (!/^[A-Z]+$/.test(method) || !["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"].includes(method)) {
1321
+ throw new CliError("INVALID_ARGUMENT", `unsupported HTTP method ${method}`, "Use an uppercase standard HTTP method.", 2);
1322
+ }
1236
1323
  const baseHeaders = requestHeaders(options);
1237
1324
  const probe = await fetchWithTimeout(url, {
1238
1325
  method,
@@ -1298,7 +1385,7 @@ async function pay(url, options) {
1298
1385
  const body = await responsePayload(paid);
1299
1386
  const paymentResponse = paid.headers.get(headers.response);
1300
1387
  const settlement = paymentResponse ? decodeResponse(paymentResponse) : undefined;
1301
- const settled = settlement !== undefined;
1388
+ const settled = settlement?.success === true && typeof settlement?.transaction === "string" && settlement.transaction.length > 0 && typeof settlement?.network === "string" && settlement.network.length > 0;
1302
1389
  const result = {
1303
1390
  url,
1304
1391
  status: paid.status,
@@ -1312,6 +1399,9 @@ async function pay(url, options) {
1312
1399
  } : {}),
1313
1400
  ...(creation.gasfreeEstimate ? { gasfreeEstimate: creation.gasfreeEstimate } : {}),
1314
1401
  };
1402
+ if (paymentResponse && !settled) {
1403
+ throw new CliError("INVALID_SETTLEMENT", "gateway returned an invalid or unsuccessful PAYMENT-RESPONSE", "Do not treat this request as paid; contact the gateway operator.", 1, result);
1404
+ }
1315
1405
  if (!paid.ok) {
1316
1406
  const retryAfter = paid.headers.get("retry-after");
1317
1407
  throw new CliError(paid.status === 429 ? "RATE_LIMITED" : "HTTP_ERROR", `HTTP ${paid.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500)}`, paymentResponse
@@ -1664,7 +1754,7 @@ async function catalogPayJson(source, name, options) {
1664
1754
  }
1665
1755
  async function handleGateway(args) {
1666
1756
  const { command, positional, options } = parseArgs(args);
1667
- if (hasFlag(options, "help") || command === "help") {
1757
+ if (hasFlag(options, "help") || ["help", "--help", "-h"].includes(command)) {
1668
1758
  process.stdout.write(helpText(command === "catalog" || positional[0] === "catalog" ? "gateway-catalog" : "gateway"));
1669
1759
  return;
1670
1760
  }
@@ -1697,7 +1787,7 @@ async function handleGatewayCatalog(positional, options) {
1697
1787
  }
1698
1788
  async function handleCatalog(args) {
1699
1789
  const { command, positional, options } = parseArgs(args);
1700
- if (hasFlag(options, "help") || command === "help") {
1790
+ if (hasFlag(options, "help") || ["help", "--help", "-h"].includes(command)) {
1701
1791
  const topic = command === "help" ? positional[0] : command;
1702
1792
  process.stdout.write(helpText(topic ? `catalog-${topic}` : "catalog"));
1703
1793
  return;
@@ -1742,7 +1832,7 @@ async function main() {
1742
1832
  }
1743
1833
  if (command === "serve") {
1744
1834
  if (hasFlag(options, "daemon"))
1745
- serveDaemon(argv, options);
1835
+ await serveDaemon(argv, options);
1746
1836
  else
1747
1837
  await serve(options);
1748
1838
  }
@@ -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,6 +116,7 @@ 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);
107
120
  const facilitatorUrl = config.operator.facilitator_url ||
108
121
  process.env.X402_FACILITATOR_URL ||
109
122
  process.env.FACILITATOR_URL ||
@@ -125,10 +138,13 @@ export function loadProvider(file) {
125
138
  };
126
139
  }
127
140
  function normalizePaymentProtocol(config, file) {
128
- const rawSchemes = config.operator.schemes?.length
129
- ? config.operator.schemes
130
- : [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"];
131
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`);
132
148
  const raw = String(value).toLowerCase();
133
149
  const normalized = raw.replace(/[-:\s]/g, "_");
134
150
  if (!["exact", "exact_gasfree", "exact_permit", "permit2", "exact_permit2"].includes(normalized)) {
@@ -151,6 +167,33 @@ function normalizePaymentProtocol(config, file) {
151
167
  delete config.operator.assetTransferMethod;
152
168
  }
153
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
+ }
154
197
  export function loadProviders(providerPath) {
155
198
  const stat = fs.statSync(providerPath);
156
199
  const files = stat.isDirectory()
@@ -174,6 +217,8 @@ export function endpointFor(provider, method, routePath) {
174
217
  return provider.endpoints?.find(endpoint => endpoint.method.toUpperCase() === method.toUpperCase() && pathMatches(endpoint.path, routePath));
175
218
  }
176
219
  function pathMatches(template, routePath) {
220
+ if (!routePath.startsWith("/") || routePath.startsWith("//") || routePath.includes("\\") || routePath.includes("\0") || /%(?:2f|5c)/i.test(routePath))
221
+ return false;
177
222
  const templateParts = template.split("/").filter(Boolean);
178
223
  const routeParts = routePath.split("/").filter(Boolean);
179
224
  if (templateParts.length !== routeParts.length)
@@ -1,4 +1,5 @@
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";
@@ -25,13 +26,16 @@ function positiveIntegerEnv(name, fallback) {
25
26
  if (!/^\d+$/.test(raw))
26
27
  throw new Error(`${name} must be a positive integer`);
27
28
  const value = Number(raw);
28
- if (!Number.isSafeInteger(value) || value <= 0)
29
- throw new Error(`${name} must be a positive integer`);
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`);
30
31
  return value;
31
32
  }
32
33
  const MAX_BODY_BYTES = positiveIntegerEnv("X402_GATEWAY_MAX_BODY_BYTES", 1_000_000);
33
34
  const FACILITATOR_TIMEOUT_MS = positiveIntegerEnv("X402_GATEWAY_FACILITATOR_TIMEOUT_MS", 10_000);
34
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);
35
39
  const STRIP_REQUEST_HEADERS = new Set([
36
40
  "host",
37
41
  "connection",
@@ -49,6 +53,8 @@ const STRIP_REQUEST_HEADERS = new Set([
49
53
  "payment-signature",
50
54
  "payment-required",
51
55
  "x-payment-required",
56
+ "payment-response",
57
+ "x-payment-response",
52
58
  "accept-encoding",
53
59
  ]);
54
60
  const STRIP_RESPONSE_HEADERS = new Set([
@@ -73,6 +79,7 @@ function createMetrics() {
73
79
  verifyFailures: 0,
74
80
  settleFailures: 0,
75
81
  upstreamFailures: 0,
82
+ rejectedRequests: 0,
76
83
  };
77
84
  }
78
85
  async function readBody(request) {
@@ -88,14 +95,17 @@ async function readBody(request) {
88
95
  return Buffer.concat(chunks);
89
96
  }
90
97
  function json(response, status, body, extraHeaders = {}) {
91
- 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 });
92
99
  response.end(JSON.stringify(body));
93
100
  }
94
101
  async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
95
102
  const controller = new AbortController();
96
103
  const timer = setTimeout(() => controller.abort(), timeoutMs);
97
104
  try {
98
- 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;
99
109
  }
100
110
  catch (error) {
101
111
  if (error instanceof Error && error.name === "AbortError")
@@ -106,13 +116,30 @@ async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
106
116
  clearTimeout(timer);
107
117
  }
108
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
+ }
109
136
  async function facilitatorPost(entry, path, body) {
110
137
  const headers = { "content-type": "application/json" };
111
138
  if (entry.facilitatorApiKey) {
112
139
  headers["x-api-key"] = entry.facilitatorApiKey;
113
140
  }
114
- const response = await fetchWithTimeout(new URL(path, entry.facilitatorUrl), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator");
115
- 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");
116
143
  let data = {};
117
144
  try {
118
145
  data = text ? JSON.parse(text) : {};
@@ -173,19 +200,19 @@ function isVerifySuccess(verify) {
173
200
  return verify?.valid === true || verify?.isValid === true;
174
201
  }
175
202
  function isSettleSuccess(settle) {
176
- if (settle?.success === false || settle?.settled === false)
177
- return false;
178
- return (settle?.success === true ||
179
- settle?.settled === true ||
180
- (typeof settle?.transaction === "string" && settle.transaction.length > 0) ||
181
- (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;
182
204
  }
183
205
  function isAdminAllowed(request) {
184
206
  const token = process.env.X402_GATEWAY_ADMIN_TOKEN;
185
207
  if (!token)
186
208
  return process.env.X402_GATEWAY_ADMIN_ALLOW_PUBLIC === "true";
187
209
  const auth = request.headers.authorization ?? "";
188
- 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";
189
216
  }
190
217
  function requestParams(url, body, request) {
191
218
  const params = {};
@@ -212,11 +239,12 @@ function requestParams(url, body, request) {
212
239
  }
213
240
  function upstreamHeaders(request, entry) {
214
241
  const headersOut = new Headers();
242
+ const connectionHeaders = new Set(String(request.headers.connection ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
215
243
  for (const [key, value] of Object.entries(request.headers)) {
216
244
  if (!value)
217
245
  continue;
218
246
  const lower = key.toLowerCase();
219
- if (STRIP_REQUEST_HEADERS.has(lower))
247
+ if (STRIP_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower))
220
248
  continue;
221
249
  headersOut.set(key, Array.isArray(value) ? value.join(",") : value);
222
250
  }
@@ -231,7 +259,14 @@ function upstreamHeaders(request, entry) {
231
259
  }
232
260
  function upstreamUrl(entry, request, routePath) {
233
261
  const sourceUrl = new URL(request.url ?? "/", "http://local");
234
- 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");
235
270
  const auth = entry.config.routing?.auth;
236
271
  const value = auth?.value ?? (auth?.value_from_env ? process.env[auth.value_from_env] : undefined);
237
272
  if (auth?.method === "query_param" && value) {
@@ -263,14 +298,24 @@ async function forward(metrics, entry, request, response, routePath, body, payme
263
298
  });
264
299
  if (paymentResponse)
265
300
  responseHeaders[headers.response] = encodeResponse(paymentResponse);
266
- const responseBody = Buffer.from(await upstreamResponse.arrayBuffer());
301
+ let responseBody;
302
+ try {
303
+ responseBody = await readResponseBytes(upstreamResponse);
304
+ }
305
+ catch (error) {
306
+ metrics.upstreamFailures += 1;
307
+ throw error;
308
+ }
267
309
  response.writeHead(upstreamResponse.status, responseHeaders);
268
310
  response.end(responseBody);
269
311
  }
270
312
  export function createGatewayServer(providers) {
271
313
  const publicBaseUrl = configuredPublicBaseUrl();
272
314
  const metrics = createMetrics();
273
- return http.createServer(async (request, response) => {
315
+ const rateLimits = new Map();
316
+ let activeRequests = 0;
317
+ const server = http.createServer(async (request, response) => {
318
+ let countedActive = false;
274
319
  try {
275
320
  metrics.requests += 1;
276
321
  const url = new URL(request.url ?? "/", "http://local");
@@ -316,6 +361,7 @@ export function createGatewayServer(providers) {
316
361
  `x402_gateway_verify_failures_total ${metrics.verifyFailures}`,
317
362
  `x402_gateway_settle_failures_total ${metrics.settleFailures}`,
318
363
  `x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`,
364
+ `x402_gateway_rejected_requests_total ${metrics.rejectedRequests}`,
319
365
  "",
320
366
  ].join("\n"));
321
367
  return;
@@ -336,8 +382,30 @@ export function createGatewayServer(providers) {
336
382
  json(response, 404, { error: "endpoint not found" });
337
383
  return;
338
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;
339
404
  const body = await readBody(request);
340
- 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");
341
409
  if (!requirements.length) {
342
410
  await forward(metrics, entry, request, response, routePath, body);
343
411
  return;
@@ -416,5 +484,14 @@ export function createGatewayServer(providers) {
416
484
  console.error(error);
417
485
  json(response, 500, { error: "internal server error" });
418
486
  }
487
+ finally {
488
+ if (countedActive)
489
+ activeRequests -= 1;
490
+ }
419
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;
420
497
  }
package/dist/tokens.js CHANGED
@@ -110,8 +110,8 @@ export function assertRawAmount(value, name = "raw amount") {
110
110
  return value.replace(/^0+(?=\d)/, "");
111
111
  }
112
112
  export function toSmallestUnit(amount, decimals) {
113
- if (!Number.isInteger(decimals) || decimals < 0)
114
- throw new Error("token decimals must be a non-negative integer");
113
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255)
114
+ throw new Error("token decimals must be an integer between 0 and 255");
115
115
  if (!/^\d+(\.\d+)?$/.test(amount))
116
116
  throw new Error("amount must be a non-negative decimal string");
117
117
  const [whole, fraction = ""] = amount.split(".");
package/dist/x402.js CHANGED
@@ -106,7 +106,7 @@ function evmRpcUrl(network, explicit) {
106
106
  (chainId === "56" ? "https://bsc-dataseed.binance.org" : undefined) ||
107
107
  (chainId === "97" ? "https://data-seed-prebsc-1-s1.binance.org:8545" : undefined));
108
108
  }
109
- async function createTronWallet(privateKey) {
109
+ async function createTronWallet(privateKey, maxGasfreeFeeRaw) {
110
110
  const rawKey = privateKey.replace(/^0x/, "");
111
111
  const tronWeb = new TronWeb({ fullHost: "https://api.trongrid.io" });
112
112
  const address = TronWeb.address.fromPrivateKey(rawKey);
@@ -115,6 +115,12 @@ async function createTronWallet(privateKey) {
115
115
  return {
116
116
  getAddress: () => address,
117
117
  async signTypedData(args) {
118
+ if (maxGasfreeFeeRaw !== undefined && args?.primaryType === "PermitTransfer") {
119
+ const maxFee = BigInt(args?.message?.maxFee ?? -1);
120
+ if (maxFee < 0n || maxFee > BigInt(maxGasfreeFeeRaw)) {
121
+ throw new Error(`final GasFree maxFee ${maxFee} exceeds --max-gasfree-fee limit ${maxGasfreeFeeRaw}`);
122
+ }
123
+ }
118
124
  const signature = await signTronTypedData(tronWeb, args, rawKey);
119
125
  return signature.startsWith("0x") ? signature : `0x${signature}`;
120
126
  },
@@ -149,7 +155,7 @@ export async function createPaymentPayload(args) {
149
155
  }
150
156
  if (selected.network.startsWith("tron:")) {
151
157
  const privateKey = privateKeyFrom(["TRON_PRIVATE_KEY", "AGENT_WALLET_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey, ["tron_client", "payer", "default"]);
152
- const wallet = await createTronWallet(privateKey);
158
+ const wallet = await createTronWallet(privateKey, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined);
153
159
  const signer = await createClientTronSigner(wallet, {
154
160
  network: selected.network,
155
161
  rpcUrl: args.rpcUrl || process.env.TRON_RPC_URL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bankofai/x402-cli",
3
- "version": "1.0.1-beta.7",
3
+ "version": "1.0.1-beta.8",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -13,6 +13,7 @@
13
13
  "bundle:gateway": "node scripts/bundle-gateway.mjs",
14
14
  "prepack": "npm run build && npm run bundle:gateway",
15
15
  "test": "npm run build && node --test tests/*.test.mjs",
16
+ "test:package": "node scripts/test-package.mjs",
16
17
  "start": "node dist/cli.js",
17
18
  "dev": "tsx src/cli.ts"
18
19
  },
@@ -22,7 +23,7 @@
22
23
  "dependencies": {
23
24
  "@bankofai/x402-core": "1.0.1",
24
25
  "@bankofai/x402-evm": "1.0.1",
25
- "@bankofai/x402-gateway": "1.0.1-beta.6",
26
+ "@bankofai/x402-gateway": "1.0.1-beta.7",
26
27
  "@bankofai/x402-tron": "1.0.1",
27
28
  "tronweb": "6.4.0",
28
29
  "viem": "^2.55.0",