@bankofai/x402-cli 1.0.0-beta.4 → 1.0.0-beta.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/dist/cli.js CHANGED
@@ -8,9 +8,9 @@ import { spawn } from "node:child_process";
8
8
  import { createRequire } from "node:module";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import YAML from "yaml";
11
- import { createPaymentPayload, decodeRequired, decodeResponse, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
12
- import { findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
13
- const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "help", "human", "include-blocked", "json", "version"]);
11
+ import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
12
+ import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
13
+ const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "version"]);
14
14
  const require = createRequire(import.meta.url);
15
15
  function parseArgs(argv) {
16
16
  const [command = "help", ...rest] = argv;
@@ -55,7 +55,7 @@ function parseArgs(argv) {
55
55
  else if (BOOLEAN_FLAGS.has(key)) {
56
56
  options[key] = true;
57
57
  }
58
- else if (!next || next.startsWith("-")) {
58
+ else if (!next || next.startsWith("--")) {
59
59
  options[key] = true;
60
60
  }
61
61
  else {
@@ -344,7 +344,11 @@ function readYaml(file) {
344
344
  return YAML.parse(fs.readFileSync(file, "utf8"));
345
345
  }
346
346
  function expandEnv(value) {
347
- return value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
347
+ return value.replace(/\$\{([^}]+)\}/g, (_, name) => {
348
+ if (!(name in process.env))
349
+ throw new Error(`environment variable \${${name}} is not set`);
350
+ return process.env[name] ?? "";
351
+ });
348
352
  }
349
353
  function expandDeep(value) {
350
354
  if (typeof value === "string")
@@ -386,6 +390,14 @@ function validateProvider(provider, file = "provider.yml") {
386
390
  if (typeof value !== "string" || !value.trim())
387
391
  throw new Error(`${file}: ${name} is required`);
388
392
  }
393
+ try {
394
+ const url = new URL(provider.forward_url);
395
+ if (!["http:", "https:"].includes(url.protocol))
396
+ throw new Error("unsupported protocol");
397
+ }
398
+ catch {
399
+ throw new Error(`${file}: forward_url must be a valid http(s) URL`);
400
+ }
389
401
  if (!Array.isArray(provider.endpoints) || !provider.endpoints.length) {
390
402
  throw new Error(`${file}: endpoints must contain at least one endpoint`);
391
403
  }
@@ -394,6 +406,12 @@ function validateProvider(provider, file = "provider.yml") {
394
406
  if (typeof endpoint.method !== "string" || typeof endpoint.path !== "string") {
395
407
  throw new Error(`${file}: each endpoint needs method and path`);
396
408
  }
409
+ if (!endpoint.method.trim() || !endpoint.path.trim() || !endpoint.path.startsWith("/")) {
410
+ throw new Error(`${file}: endpoint method/path must be non-empty and path must start with /`);
411
+ }
412
+ const price = providerPrice(endpoint);
413
+ if (!Number.isFinite(price) || price < 0)
414
+ throw new Error(`${file}: endpoint price_usd must be a finite number >= 0`);
397
415
  const key = `${endpoint.method.toUpperCase()} ${endpoint.path}`;
398
416
  if (seen.has(key))
399
417
  throw new Error(`${file}: duplicate endpoint ${key}`);
@@ -568,11 +586,42 @@ function cachedCatalogPath() {
568
586
  function providerFilename(fqn) {
569
587
  return `${fqn.replace(/\//g, "__")}.json`;
570
588
  }
589
+ function sanitizeProviderName(name) {
590
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(name) || name.includes("..")) {
591
+ throw new Error("provider name must be a safe FQN using letters, numbers, dots, underscores, dashes, or slashes");
592
+ }
593
+ return name;
594
+ }
595
+ function safeOutputPath(baseDir, ...parts) {
596
+ const root = path.resolve(baseDir);
597
+ const target = path.resolve(root, ...parts);
598
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
599
+ throw new Error(`refusing to write outside output directory: ${target}`);
600
+ }
601
+ return target;
602
+ }
603
+ function ensureWritable(file, options) {
604
+ if (fs.existsSync(file) && !hasFlag(options, "force")) {
605
+ throw new Error(`${file} already exists; pass --force to overwrite`);
606
+ }
607
+ }
571
608
  function defaultCatalogSource() {
609
+ const envSource = process.env.X402_CATALOG || process.env.X402_GATEWAY_CATALOG;
610
+ if (envSource)
611
+ return envSource;
572
612
  return fs.existsSync(cachedCatalogPath())
573
613
  ? cachedCatalogPath()
574
614
  : "https://x402-catalog.bankofai.io/api/catalog.json";
575
615
  }
616
+ function positiveIntegerOption(options, key, fallback) {
617
+ const value = opt(options, key, String(fallback));
618
+ if (!/^\d+$/.test(value))
619
+ throw new Error(`--${key} must be a positive integer`);
620
+ const parsed = Number(value);
621
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
622
+ throw new Error(`--${key} must be a positive integer`);
623
+ return parsed;
624
+ }
576
625
  function remoteBaseFromCatalogPayload(payload) {
577
626
  const base = payload.base_url ?? payload.baseUrl;
578
627
  return typeof base === "string" && /^https?:\/\//.test(base) ? `${base.replace(/\/+$/, "")}/` : undefined;
@@ -599,7 +648,7 @@ function catalogDetailSource(source, section, name) {
599
648
  : base.pathname.endsWith("/")
600
649
  ? base.pathname
601
650
  : `${base.pathname}/`;
602
- base.pathname = `${pathname}${section}/${name}.json`;
651
+ base.pathname = `${pathname}${section}/${providerFilename(name)}`;
603
652
  base.search = "";
604
653
  base.hash = "";
605
654
  return base.toString();
@@ -616,8 +665,6 @@ async function readCatalogProvider(source, name) {
616
665
  const summary = providers.find((item) => item.name === name || item.fqn === name);
617
666
  if (!summary)
618
667
  throw new Error(`provider not found: ${name}`);
619
- if (Array.isArray(summary.endpoints) && summary.endpoints.length)
620
- return summary;
621
668
  const fqn = summary.fqn ?? summary.name ?? name;
622
669
  try {
623
670
  return await readJson(catalogDetailSource(source, "providers", fqn));
@@ -764,12 +811,18 @@ async function catalogExportGateway(gatewayUrl, options) {
764
811
  const providerFqn = opt(options, "provider");
765
812
  if (!providerFqn)
766
813
  throw new Error("--provider is required");
814
+ sanitizeProviderName(providerFqn);
767
815
  const base = gatewayUrl.replace(/\/+$/, "");
768
- const detail = await readJson(`${base}/__402/catalog/providers/${providerFqn}.json`);
769
- const target = opt(options, "output-dir", path.join("providers", providerFqn));
816
+ const detail = await readJson(`${base}/__402/catalog/providers/${providerFilename(providerFqn)}`);
817
+ const outputRoot = opt(options, "output-dir", "providers");
818
+ const target = opt(options, "output-dir")
819
+ ? path.resolve(outputRoot)
820
+ : safeOutputPath("providers", providerFilename(providerFqn).replace(/\.json$/, ""));
770
821
  fs.mkdirSync(target, { recursive: true });
771
822
  const catalogPath = path.join(target, "catalog.json");
772
823
  const payMdPath = path.join(target, "pay.md");
824
+ ensureWritable(catalogPath, options);
825
+ ensureWritable(payMdPath, options);
773
826
  writeJson(catalogPath, submissionCatalog(detail));
774
827
  fs.writeFileSync(payMdPath, payMarkdownFromDetail(detail));
775
828
  emit({ command: "catalog export-gateway", mode: outputMode(options), result: { provider: providerFqn, catalog: catalogPath, payMd: payMdPath } });
@@ -790,7 +843,11 @@ function buildRequirement(options) {
790
843
  const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken.decimals;
791
844
  if (!Number.isInteger(decimals) || decimals < 0)
792
845
  throw new Error("--decimals must be a non-negative integer");
793
- const amount = opt(options, "rawAmount") ?? toSmallestUnit(opt(options, "amount", "0.0001"), decimals);
846
+ const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount");
847
+ const humanAmount = opt(options, "amount");
848
+ if (rawAmount && humanAmount)
849
+ throw new Error("--amount and --rawAmount are mutually exclusive");
850
+ const amount = rawAmount ? assertRawAmount(rawAmount, "--rawAmount") : toSmallestUnit(humanAmount ?? "0.0001", decimals);
794
851
  const assetAddress = explicitAsset ?? registryToken.address;
795
852
  const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2";
796
853
  return {
@@ -829,6 +886,9 @@ function stripFlag(argv, flag) {
829
886
  return argv.filter(item => item !== flag);
830
887
  }
831
888
  function serveDaemon(argv, options) {
889
+ const requirement = buildRequirement(options);
890
+ if (!requirement.payTo)
891
+ throw new Error("--pay-to is required");
832
892
  const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d");
833
893
  const script = fileURLToPath(import.meta.url);
834
894
  const child = spawn(process.execPath, [script, ...daemonArgs], {
@@ -843,7 +903,7 @@ function serveDaemon(argv, options) {
843
903
  emit({
844
904
  command: "server",
845
905
  mode: outputMode(options),
846
- network: normalizeNetwork(opt(options, "network", "tron:nile")),
906
+ network: requirement.network,
847
907
  scheme: "exact",
848
908
  result: {
849
909
  pid: child.pid,
@@ -856,7 +916,7 @@ function serveDaemon(argv, options) {
856
916
  function validateAmountLimits(selected, options) {
857
917
  const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount");
858
918
  const maxAmount = opt(options, "max-amount");
859
- if (maxRaw && BigInt(selected.amount) > BigInt(maxRaw)) {
919
+ if (maxRaw && BigInt(selected.amount) > BigInt(assertRawAmount(maxRaw, "--max-rawAmount"))) {
860
920
  throw new Error(`payment raw amount ${selected.amount} exceeds --max-rawAmount ${maxRaw}`);
861
921
  }
862
922
  if (maxAmount) {
@@ -889,17 +949,18 @@ async function serve(options) {
889
949
  };
890
950
  const server = http.createServer(async (request, response) => {
891
951
  try {
892
- if (request.url?.startsWith("/health")) {
952
+ const pathname = new URL(request.url ?? "/", `http://${host}:${port}`).pathname;
953
+ if (pathname === "/health") {
893
954
  response.writeHead(200, { "content-type": "application/json" });
894
955
  response.end(JSON.stringify({ ok: true }));
895
956
  return;
896
957
  }
897
- if (request.url?.startsWith("/.well-known/x402")) {
958
+ if (pathname === "/.well-known/x402") {
898
959
  response.writeHead(200, { "content-type": "application/json" });
899
- response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl }));
960
+ response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl }));
900
961
  return;
901
962
  }
902
- if (!request.url?.startsWith("/pay")) {
963
+ if (pathname !== "/pay") {
903
964
  response.writeHead(404).end("not found");
904
965
  return;
905
966
  }
@@ -912,22 +973,33 @@ async function serve(options) {
912
973
  response.end(JSON.stringify(challenge));
913
974
  return;
914
975
  }
915
- const payload = Buffer.from(signature, "base64").toString("utf8").startsWith("{")
916
- ? JSON.parse(Buffer.from(signature, "base64").toString("utf8"))
917
- : signature;
976
+ let payload;
977
+ try {
978
+ payload = decodeSignature(signature);
979
+ }
980
+ catch {
981
+ response.writeHead(400, { "content-type": "application/json" });
982
+ response.end(JSON.stringify({ error: "invalid payment signature" }));
983
+ return;
984
+ }
918
985
  const verify = await facilitatorPost(facilitatorUrl, "/verify", {
919
986
  paymentPayload: payload,
920
987
  paymentRequirements: requirement,
921
988
  });
922
- if (verify?.valid === false || verify?.isValid === false) {
989
+ if (!(verify?.valid === true || verify?.isValid === true)) {
923
990
  response.writeHead(400, { "content-type": "application/json" });
924
- response.end(JSON.stringify({ error: "payment verification failed", verify }));
991
+ response.end(JSON.stringify({ error: "payment verification failed" }));
925
992
  return;
926
993
  }
927
994
  const settle = await facilitatorPost(facilitatorUrl, "/settle", {
928
995
  paymentPayload: payload,
929
996
  paymentRequirements: requirement,
930
997
  });
998
+ if (!(settle?.success === true || settle?.settled === true || typeof settle?.transaction === "string" || typeof settle?.txHash === "string")) {
999
+ response.writeHead(502, { "content-type": "application/json" });
1000
+ response.end(JSON.stringify({ error: "settlement failed" }));
1001
+ return;
1002
+ }
931
1003
  response.writeHead(200, {
932
1004
  "content-type": "application/json",
933
1005
  [headers.response]: encodeResponse(settle),
@@ -939,13 +1011,18 @@ async function serve(options) {
939
1011
  response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
940
1012
  }
941
1013
  });
942
- server.listen(port, host, () => {
943
- emit({
944
- command: "server",
945
- network: requirement.network,
946
- scheme: "exact",
947
- mode: outputMode(options),
948
- result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo },
1014
+ await new Promise((resolve, reject) => {
1015
+ server.once("error", reject);
1016
+ server.listen(port, host, () => {
1017
+ server.off("error", reject);
1018
+ emit({
1019
+ command: "server",
1020
+ network: requirement.network,
1021
+ scheme: "exact",
1022
+ mode: outputMode(options),
1023
+ result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo },
1024
+ });
1025
+ resolve();
949
1026
  });
950
1027
  });
951
1028
  }
@@ -1025,7 +1102,7 @@ async function pay(url, options) {
1025
1102
  const paid = await fetch(url, {
1026
1103
  method,
1027
1104
  headers: retryHeaders,
1028
- body: opt(options, "body"),
1105
+ body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"),
1029
1106
  });
1030
1107
  const body = await responsePayload(paid);
1031
1108
  const paymentResponse = paid.headers.get(headers.response);
@@ -1281,7 +1358,7 @@ async function searchCatalog(source, query, options) {
1281
1358
  }
1282
1359
  return hits
1283
1360
  .sort((a, b) => b.score - a.score || a.fqn.localeCompare(b.fqn))
1284
- .slice(0, Number(opt(options, "limit", "10")));
1361
+ .slice(0, positiveIntegerOption(options, "limit", 10));
1285
1362
  }
1286
1363
  function searchHitToJson(hit) {
1287
1364
  return {
@@ -1,33 +1,218 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs";
2
3
  import { loadProviders } from "./config.js";
3
4
  import { createGatewayServer } from "./server.js";
5
+ class CliError extends Error {
6
+ exitCode;
7
+ constructor(message, exitCode = 2) {
8
+ super(message);
9
+ this.exitCode = exitCode;
10
+ }
11
+ }
12
+ const VALUE_OPTIONS = new Set(["provider", "providers", "host", "port"]);
13
+ const BOOLEAN_OPTIONS = new Set(["debug", "help", "json", "quiet", "version"]);
14
+ const ALL_OPTIONS = new Set([...VALUE_OPTIONS, ...BOOLEAN_OPTIONS]);
4
15
  function parseArgs(argv) {
16
+ const explicitCommand = Boolean(argv[0] && !argv[0].startsWith("-"));
17
+ const command = explicitCommand ? argv[0] : "start";
18
+ const rest = explicitCommand ? argv.slice(1) : argv;
5
19
  const options = {};
6
- for (let i = 0; i < argv.length; i += 1) {
7
- const item = argv[i];
8
- if (!item.startsWith("--"))
20
+ if (command !== "start" && command !== "check") {
21
+ throw new CliError(`Unknown command: ${command}`);
22
+ }
23
+ for (let i = 0; i < rest.length; i += 1) {
24
+ const item = rest[i];
25
+ if (item === "-h") {
26
+ options.help = true;
9
27
  continue;
10
- const key = item.slice(2);
11
- const next = argv[i + 1];
12
- if (!next || next.startsWith("--"))
28
+ }
29
+ if (item === "-v" || item === "-V") {
30
+ options.version = true;
31
+ continue;
32
+ }
33
+ if (!item.startsWith("--"))
34
+ throw new CliError(`Unexpected argument: ${item}`);
35
+ const eq = item.indexOf("=");
36
+ const key = eq > 2 ? item.slice(2, eq) : item.slice(2);
37
+ if (!ALL_OPTIONS.has(key))
38
+ throw new CliError(`Unknown option: --${key}`);
39
+ if (BOOLEAN_OPTIONS.has(key)) {
40
+ if (eq > 2)
41
+ throw new CliError(`Option --${key} does not take a value`);
13
42
  options[key] = true;
43
+ continue;
44
+ }
45
+ const inline = eq > 2 ? item.slice(eq + 1) : undefined;
46
+ const next = rest[i + 1];
47
+ if (inline !== undefined) {
48
+ if (!inline)
49
+ throw new CliError(`Option --${key} requires a value`);
50
+ options[key] = inline;
51
+ }
14
52
  else {
53
+ if (!next || next.startsWith("--"))
54
+ throw new CliError(`Option --${key} requires a value`);
15
55
  options[key] = next;
16
56
  i += 1;
17
57
  }
18
58
  }
19
- return options;
59
+ return { command, options };
20
60
  }
21
61
  function opt(options, key, fallback) {
22
62
  const value = options[key];
23
63
  return typeof value === "string" ? value : fallback;
24
64
  }
25
- const options = parseArgs(process.argv.slice(2));
26
- const providersPath = opt(options, "providers", opt(options, "provider", process.env.X402_GATEWAY_PROVIDERS_DIR || "/app/providers"));
27
- const host = opt(options, "host", process.env.X402_GATEWAY_HOST || "0.0.0.0");
28
- const port = Number(opt(options, "port", process.env.PORT || "8080"));
29
- const providers = loadProviders(providersPath);
30
- const server = createGatewayServer(providers);
31
- server.listen(port, host, () => {
32
- process.stdout.write(JSON.stringify({ ok: true, host, port, providers: [...providers.keys()] }, null, 2) + "\n");
65
+ function flag(options, key) {
66
+ return options[key] === true;
67
+ }
68
+ function version() {
69
+ try {
70
+ const url = new URL("../package.json", import.meta.url);
71
+ return String(JSON.parse(fs.readFileSync(url, "utf8")).version ?? "0.0.0");
72
+ }
73
+ catch {
74
+ return "0.0.0";
75
+ }
76
+ }
77
+ function help() {
78
+ return `x402-gateway ${version()}
79
+
80
+ Usage:
81
+ x402-gateway start --providers <dir> [options]
82
+ x402-gateway --providers <dir> [options]
83
+ x402-gateway check --providers <dir>
84
+ x402-gateway check --provider <file>
85
+
86
+ Commands:
87
+ start Start the x402 gateway server (default)
88
+ check Validate provider YAML and print a summary
89
+
90
+ Options:
91
+ --provider <file> Load one provider YAML file
92
+ --providers <dir> Load provider.yml/provider.yaml files from a directory
93
+ --host <host> Bind host (default: 127.0.0.1)
94
+ --port <port> Bind port (default: 8080)
95
+ --json Print machine-readable JSON
96
+ --quiet Suppress startup/shutdown messages
97
+ --debug Print stack traces for startup errors
98
+ -h, --help Show help
99
+ -v, -V, --version Show version
100
+
101
+ Examples:
102
+ x402-gateway --provider examples/provider.yml --host 127.0.0.1 --port 4020
103
+ x402-gateway start --providers providers --port 4020
104
+ x402-gateway check --providers providers --json
105
+ `;
106
+ }
107
+ function providerPath(options) {
108
+ const source = opt(options, "providers", opt(options, "provider", process.env.X402_GATEWAY_PROVIDERS_DIR));
109
+ if (!source)
110
+ throw new CliError("No provider source configured. Use --provider <file> or --providers <dir>.");
111
+ return source;
112
+ }
113
+ function parsePort(value) {
114
+ const raw = value ?? process.env.PORT ?? "8080";
115
+ if (!/^\d+$/.test(raw))
116
+ throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
117
+ const port = Number(raw);
118
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
119
+ throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
120
+ }
121
+ return port;
122
+ }
123
+ function publicHost(host) {
124
+ return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
125
+ }
126
+ function printStartup(options, host, port, providers) {
127
+ if (flag(options, "quiet"))
128
+ return;
129
+ if (flag(options, "json")) {
130
+ process.stdout.write(JSON.stringify({ ok: true, host, port, providers }, null, 2) + "\n");
131
+ return;
132
+ }
133
+ const base = `http://${publicHost(host)}:${port}`;
134
+ process.stdout.write(`x402-gateway listening on ${base}\n`);
135
+ process.stdout.write(`providers: ${providers.length} loaded\n`);
136
+ process.stdout.write(`health: ${base}/__402/health\n`);
137
+ process.stdout.write(`ready: ${base}/__402/ready\n`);
138
+ }
139
+ function check(options) {
140
+ const source = providerPath(options);
141
+ const providers = loadProviders(source);
142
+ const summary = [...providers.values()].map(entry => ({
143
+ name: entry.config.name,
144
+ network: entry.config.operator.network,
145
+ endpoints: entry.config.endpoints?.length ?? 0,
146
+ }));
147
+ if (flag(options, "json")) {
148
+ process.stdout.write(JSON.stringify({ ok: true, source, count: summary.length, providers: summary }, null, 2) + "\n");
149
+ return;
150
+ }
151
+ process.stdout.write(`ok: ${summary.length} provider${summary.length === 1 ? "" : "s"} loaded from ${source}\n`);
152
+ for (const item of summary) {
153
+ process.stdout.write(` ${item.name} (${item.network}) endpoints=${item.endpoints}\n`);
154
+ }
155
+ }
156
+ async function start(options) {
157
+ const source = providerPath(options);
158
+ const host = opt(options, "host", process.env.X402_GATEWAY_HOST || "127.0.0.1");
159
+ const port = parsePort(opt(options, "port"));
160
+ const providers = loadProviders(source);
161
+ const server = createGatewayServer(providers);
162
+ await new Promise((resolve, reject) => {
163
+ server.once("error", reject);
164
+ server.listen(port, host, () => {
165
+ server.off("error", reject);
166
+ printStartup(options, host, port, [...providers.keys()]);
167
+ resolve();
168
+ });
169
+ });
170
+ const shutdown = (signal) => {
171
+ if (!flag(options, "quiet") && !flag(options, "json"))
172
+ process.stderr.write(`received ${signal}, shutting down...\n`);
173
+ server.close(() => {
174
+ if (!flag(options, "quiet") && !flag(options, "json"))
175
+ process.stderr.write("server closed\n");
176
+ process.exit(0);
177
+ });
178
+ };
179
+ process.once("SIGINT", shutdown);
180
+ process.once("SIGTERM", shutdown);
181
+ }
182
+ async function main() {
183
+ const parsed = parseArgs(process.argv.slice(2));
184
+ if (flag(parsed.options, "help")) {
185
+ process.stdout.write(help());
186
+ return;
187
+ }
188
+ if (flag(parsed.options, "version")) {
189
+ process.stdout.write(`${version()}\n`);
190
+ return;
191
+ }
192
+ if (parsed.command === "check")
193
+ check(parsed.options);
194
+ else
195
+ await start(parsed.options);
196
+ }
197
+ main().catch(error => {
198
+ const parsed = (() => {
199
+ try {
200
+ return parseArgs(process.argv.slice(2));
201
+ }
202
+ catch {
203
+ return { options: {} };
204
+ }
205
+ })();
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ if (flag(parsed.options, "json")) {
208
+ process.stdout.write(JSON.stringify({ ok: false, error: { message } }, null, 2) + "\n");
209
+ }
210
+ else {
211
+ process.stderr.write(`x402-gateway: ${message}\n`);
212
+ process.stderr.write("Run `x402-gateway --help` for usage.\n");
213
+ if (flag(parsed.options, "debug") && error instanceof Error && error.stack) {
214
+ process.stderr.write(`${error.stack}\n`);
215
+ }
216
+ }
217
+ process.exit(error instanceof CliError ? error.exitCode : 1);
33
218
  });
@@ -3,7 +3,11 @@ import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
5
5
  function expandEnv(value) {
6
- return value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
6
+ return value.replace(/\$\{([^}]+)\}/g, (_, name) => {
7
+ if (!(name in process.env))
8
+ throw new Error(`environment variable \${${name}} is not set`);
9
+ return process.env[name] ?? "";
10
+ });
7
11
  }
8
12
  function expandDeep(value) {
9
13
  if (typeof value === "string")
@@ -19,11 +23,63 @@ function assertString(value, name) {
19
23
  if (typeof value !== "string" || !value.trim())
20
24
  throw new Error(`${name} is required`);
21
25
  }
26
+ function assertHttpUrl(value, name) {
27
+ if (!value)
28
+ return;
29
+ try {
30
+ const url = new URL(value);
31
+ if (!["http:", "https:"].includes(url.protocol))
32
+ throw new Error("unsupported protocol");
33
+ }
34
+ catch {
35
+ throw new Error(`${name} must be a valid http(s) URL`);
36
+ }
37
+ }
38
+ function validateTiers(tiers, file, path) {
39
+ if (!Array.isArray(tiers) || !tiers.length)
40
+ throw new Error(`${file}: ${path}.tiers must contain at least one tier`);
41
+ for (const [tierIndex, tier] of tiers.entries()) {
42
+ const price = tier?.price_usd;
43
+ if (typeof price !== "number" || !Number.isFinite(price) || price < 0) {
44
+ throw new Error(`${file}: ${path}.tiers[${tierIndex}].price_usd must be a finite number >= 0`);
45
+ }
46
+ }
47
+ }
48
+ function validateDimensions(dimensions, file, path) {
49
+ if (!Array.isArray(dimensions) || !dimensions.length) {
50
+ throw new Error(`${file}: ${path}.dimensions must contain at least one dimension`);
51
+ }
52
+ if (dimensions.length !== 1)
53
+ throw new Error(`${file}: ${path}.dimensions currently supports exactly one dimension`);
54
+ for (const [dimensionIndex, dimension] of dimensions.entries()) {
55
+ validateTiers(dimension?.tiers, file, `${path}.dimensions[${dimensionIndex}]`);
56
+ }
57
+ }
58
+ function validateMetering(endpoint, file, path) {
59
+ if (!endpoint.metering)
60
+ return;
61
+ validateDimensions(endpoint.metering.dimensions, file, `${path}.metering`);
62
+ for (const [variantIndex, variant] of (endpoint.metering.variants ?? []).entries()) {
63
+ assertString(variant.param, `${file}: ${path}.metering.variants[${variantIndex}].param`);
64
+ assertString(variant.value, `${file}: ${path}.metering.variants[${variantIndex}].value`);
65
+ validateDimensions(variant.dimensions, file, `${path}.metering.variants[${variantIndex}]`);
66
+ }
67
+ }
22
68
  function validateProvider(config, file) {
23
69
  assertString(config.name, `${file}: name`);
24
70
  assertString(config.forward_url, `${file}: forward_url`);
25
71
  assertString(config.operator?.network, `${file}: operator.network`);
26
72
  assertString(config.operator?.recipient, `${file}: operator.recipient`);
73
+ assertHttpUrl(config.forward_url, `${file}: forward_url`);
74
+ assertHttpUrl(config.operator?.facilitator_url, `${file}: operator.facilitator_url`);
75
+ if (config.operator?.valid_for_seconds !== undefined &&
76
+ (!Number.isFinite(config.operator.valid_for_seconds) || config.operator.valid_for_seconds <= 0)) {
77
+ throw new Error(`${file}: operator.valid_for_seconds must be > 0`);
78
+ }
79
+ const authMethod = config.routing?.auth?.method;
80
+ if (authMethod && !["header", "query_param", "access_token", "oauth2"].includes(authMethod)) {
81
+ throw new Error(`${file}: unsupported routing.auth.method ${authMethod}`);
82
+ }
27
83
  if (!config.endpoints?.length)
28
84
  throw new Error(`${file}: endpoints must contain at least one endpoint`);
29
85
  const seen = new Set();
@@ -40,11 +96,7 @@ function validateProvider(config, file) {
40
96
  if (seen.has(key))
41
97
  throw new Error(`${file}: duplicate endpoint ${key}`);
42
98
  seen.add(key);
43
- for (const [tierIndex, tier] of (endpoint.metering?.dimensions?.[0]?.tiers ?? []).entries()) {
44
- if (typeof tier.price_usd !== "number" || tier.price_usd < 0) {
45
- throw new Error(`${file}: endpoints[${index}].metering tier ${tierIndex} price_usd must be >= 0`);
46
- }
47
- }
99
+ validateMetering(endpoint, file, `endpoints[${index}]`);
48
100
  }
49
101
  }
50
102
  export function loadProvider(file) {
@@ -111,8 +163,14 @@ function pathMatches(template, routePath) {
111
163
  });
112
164
  }
113
165
  export function priceUsd(endpoint, params = {}) {
166
+ if (!endpoint?.metering)
167
+ return 0;
114
168
  const variant = endpoint?.metering?.variants?.find(item => params[item.param] === item.value);
115
- return (variant?.dimensions ?? endpoint?.metering?.dimensions)?.[0]?.tiers?.[0]?.price_usd ?? 0;
169
+ const price = (variant?.dimensions ?? endpoint.metering.dimensions)?.[0]?.tiers?.[0]?.price_usd;
170
+ if (typeof price !== "number" || !Number.isFinite(price) || price < 0) {
171
+ throw new Error(`invalid metering price for ${endpoint.method} ${endpoint.path}`);
172
+ }
173
+ return price;
116
174
  }
117
175
  export function paymentRequirements(provider, price) {
118
176
  if (price <= 0)
@@ -123,10 +181,13 @@ export function paymentRequirements(provider, price) {
123
181
  return symbols.map(symbol => {
124
182
  const token = getToken(network, symbol);
125
183
  const transferMethod = provider.operator.assetTransferMethod || provider.operator.asset_transfer_method || token.assetTransferMethod;
184
+ const amount = toSmallestUnit(price, token.decimals);
185
+ if (amount === "0")
186
+ throw new Error(`positive price produced zero amount for ${symbol} on ${network}`);
126
187
  return {
127
188
  scheme: "exact",
128
189
  network,
129
- amount: toSmallestUnit(price, token.decimals),
190
+ amount,
130
191
  asset: token.address,
131
192
  payTo,
132
193
  maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300,
@@ -2,37 +2,106 @@ import http from "node:http";
2
2
  import { URL } from "node:url";
3
3
  import { endpointFor, paymentRequirements, priceUsd } from "./config.js";
4
4
  import { decodeSignature, encodeRequired, encodeResponse, headers, matchRequirement } from "./x402.js";
5
+ class HttpError extends Error {
6
+ status;
7
+ publicMessage;
8
+ constructor(status, publicMessage, message = publicMessage) {
9
+ super(message);
10
+ this.status = status;
11
+ this.publicMessage = publicMessage;
12
+ }
13
+ }
14
+ class RequestTooLargeError extends HttpError {
15
+ constructor() {
16
+ super(413, "request body too large");
17
+ }
18
+ }
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
+ const STRIP_REQUEST_HEADERS = new Set([
23
+ "host",
24
+ "connection",
25
+ "transfer-encoding",
26
+ "content-length",
27
+ "authorization",
28
+ "proxy-authorization",
29
+ "cookie",
30
+ "x-api-key",
31
+ "api-key",
32
+ "apikey",
33
+ "x-auth-token",
34
+ "x-access-token",
35
+ "x-payment",
36
+ "payment-signature",
37
+ "payment-required",
38
+ "x-payment-required",
39
+ "accept-encoding",
40
+ ]);
41
+ const STRIP_RESPONSE_HEADERS = new Set([
42
+ "connection",
43
+ "transfer-encoding",
44
+ "content-encoding",
45
+ "content-length",
46
+ "authorization",
47
+ "proxy-authorization",
48
+ "set-cookie",
49
+ ]);
5
50
  const metrics = {
6
51
  requests: 0,
7
52
  paidRequests: 0,
8
53
  verifyFailures: 0,
9
54
  settleFailures: 0,
55
+ feeQuoteFailures: 0,
10
56
  upstreamFailures: 0,
11
57
  };
12
58
  async function readBody(request) {
13
59
  const chunks = [];
14
- for await (const chunk of request)
15
- chunks.push(Buffer.from(chunk));
60
+ let total = 0;
61
+ for await (const chunk of request) {
62
+ const buffer = Buffer.from(chunk);
63
+ total += buffer.length;
64
+ if (total > MAX_BODY_BYTES)
65
+ throw new RequestTooLargeError();
66
+ chunks.push(buffer);
67
+ }
16
68
  return Buffer.concat(chunks);
17
69
  }
18
70
  function json(response, status, body, extraHeaders = {}) {
19
71
  response.writeHead(status, { "content-type": "application/json", ...extraHeaders });
20
72
  response.end(JSON.stringify(body));
21
73
  }
74
+ async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
77
+ try {
78
+ return await fetch(url, { ...init, signal: controller.signal });
79
+ }
80
+ catch (error) {
81
+ if (error?.name === "AbortError")
82
+ throw new HttpError(504, `${label} request timed out`);
83
+ throw error;
84
+ }
85
+ finally {
86
+ clearTimeout(timer);
87
+ }
88
+ }
22
89
  async function facilitatorPost(entry, path, body) {
23
90
  const headers = { "content-type": "application/json" };
24
91
  if (entry.facilitatorApiKey) {
25
92
  headers.authorization = `Bearer ${entry.facilitatorApiKey}`;
26
93
  }
27
- const response = await fetch(new URL(path, entry.facilitatorUrl), {
28
- method: "POST",
29
- headers,
30
- body: JSON.stringify(body),
31
- });
94
+ const response = await fetchWithTimeout(new URL(path, entry.facilitatorUrl), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator");
32
95
  const text = await response.text();
33
- const data = text ? JSON.parse(text) : {};
96
+ let data = {};
97
+ try {
98
+ data = text ? JSON.parse(text) : {};
99
+ }
100
+ catch {
101
+ throw new HttpError(502, "facilitator returned invalid response");
102
+ }
34
103
  if (!response.ok)
35
- throw new Error(`facilitator ${path} failed: ${response.status} ${text}`);
104
+ throw new HttpError(502, "facilitator request failed", `facilitator ${path} failed: ${response.status} ${text}`);
36
105
  return data;
37
106
  }
38
107
  async function attachFeeQuotes(entry, requirements, context) {
@@ -49,14 +118,25 @@ async function attachFeeQuotes(entry, requirements, context) {
49
118
  return quote?.fee ? { ...requirement, extra: { ...requirement.extra, fee: quote.fee } } : requirement;
50
119
  });
51
120
  }
52
- catch {
121
+ catch (error) {
122
+ metrics.feeQuoteFailures += 1;
123
+ console.warn("fee quote failed", error);
53
124
  return requirements;
54
125
  }
55
126
  }
127
+ function isVerifySuccess(verify) {
128
+ return verify?.valid === true || verify?.isValid === true;
129
+ }
130
+ 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));
135
+ }
56
136
  function isAdminAllowed(request) {
57
137
  const token = process.env.X402_GATEWAY_ADMIN_TOKEN;
58
138
  if (!token)
59
- return true;
139
+ return process.env.X402_GATEWAY_ADMIN_ALLOW_PUBLIC === "true";
60
140
  const auth = request.headers.authorization ?? "";
61
141
  return auth === `Bearer ${token}`;
62
142
  }
@@ -89,7 +169,7 @@ function upstreamHeaders(request, entry) {
89
169
  if (!value)
90
170
  continue;
91
171
  const lower = key.toLowerCase();
92
- if (["host", "connection", "content-length", "authorization", "payment-signature"].includes(lower))
172
+ if (STRIP_REQUEST_HEADERS.has(lower))
93
173
  continue;
94
174
  headersOut.set(key, Array.isArray(value) ? value.join(",") : value);
95
175
  }
@@ -114,14 +194,23 @@ function upstreamUrl(entry, request, routePath) {
114
194
  }
115
195
  async function forward(entry, request, response, routePath, body, paymentResponse) {
116
196
  const upstream = upstreamUrl(entry, request, routePath);
117
- const upstreamResponse = await fetch(upstream, {
118
- method: request.method,
119
- headers: upstreamHeaders(request, entry),
120
- body: ["GET", "HEAD"].includes(request.method ?? "GET") ? undefined : new Uint8Array(body),
121
- });
197
+ let upstreamResponse;
198
+ try {
199
+ upstreamResponse = await fetchWithTimeout(upstream, {
200
+ method: request.method,
201
+ headers: upstreamHeaders(request, entry),
202
+ body: ["GET", "HEAD"].includes(request.method ?? "GET") ? undefined : new Uint8Array(body),
203
+ }, UPSTREAM_TIMEOUT_MS);
204
+ }
205
+ catch (error) {
206
+ metrics.upstreamFailures += 1;
207
+ if (error instanceof HttpError)
208
+ throw error;
209
+ throw new HttpError(502, "upstream request failed");
210
+ }
122
211
  const responseHeaders = {};
123
212
  upstreamResponse.headers.forEach((value, key) => {
124
- if (!["connection", "transfer-encoding", "content-encoding", "content-length"].includes(key.toLowerCase())) {
213
+ if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) {
125
214
  responseHeaders[key] = value;
126
215
  }
127
216
  });
@@ -140,7 +229,7 @@ export function createGatewayServer(providers) {
140
229
  return;
141
230
  }
142
231
  if (url.pathname === "/__402/ready") {
143
- json(response, 200, { ok: true, providers: providers.size });
232
+ json(response, providers.size ? 200 : 503, { ok: providers.size > 0, providers: providers.size });
144
233
  return;
145
234
  }
146
235
  if (url.pathname === "/__402/providers") {
@@ -168,12 +257,15 @@ export function createGatewayServer(providers) {
168
257
  return;
169
258
  }
170
259
  if (url.pathname === "/metrics") {
260
+ if (!isAdminAllowed(request))
261
+ return json(response, 401, { error: "unauthorized" });
171
262
  response.writeHead(200, { "content-type": "text/plain; version=0.0.4" });
172
263
  response.end([
173
264
  `x402_gateway_requests_total ${metrics.requests}`,
174
265
  `x402_gateway_paid_requests_total ${metrics.paidRequests}`,
175
266
  `x402_gateway_verify_failures_total ${metrics.verifyFailures}`,
176
267
  `x402_gateway_settle_failures_total ${metrics.settleFailures}`,
268
+ `x402_gateway_fee_quote_failures_total ${metrics.feeQuoteFailures}`,
177
269
  `x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`,
178
270
  "",
179
271
  ].join("\n"));
@@ -213,7 +305,14 @@ export function createGatewayServer(providers) {
213
305
  json(response, 402, challenge, { [headers.required]: encodeRequired(challenge) });
214
306
  return;
215
307
  }
216
- const payload = decodeSignature(paymentHeader);
308
+ let payload;
309
+ try {
310
+ payload = decodeSignature(paymentHeader);
311
+ }
312
+ catch {
313
+ json(response, 400, { error: "invalid payment signature" });
314
+ return;
315
+ }
217
316
  const requirement = requirements.find(item => matchRequirement(payload, item));
218
317
  if (!requirement) {
219
318
  json(response, 400, { error: "payment does not match any requirement" });
@@ -223,9 +322,9 @@ export function createGatewayServer(providers) {
223
322
  paymentPayload: payload,
224
323
  paymentRequirements: requirement,
225
324
  });
226
- if (verify?.valid === false || verify?.isValid === false) {
325
+ if (!isVerifySuccess(verify)) {
227
326
  metrics.verifyFailures += 1;
228
- json(response, 400, { error: "payment verification failed", verify });
327
+ json(response, 400, { error: "payment verification failed" });
229
328
  return;
230
329
  }
231
330
  let settle;
@@ -239,11 +338,21 @@ export function createGatewayServer(providers) {
239
338
  metrics.settleFailures += 1;
240
339
  throw error;
241
340
  }
341
+ if (!isSettleSuccess(settle)) {
342
+ metrics.settleFailures += 1;
343
+ json(response, 502, { error: "settlement failed" });
344
+ return;
345
+ }
242
346
  metrics.paidRequests += 1;
243
347
  await forward(entry, request, response, routePath, body, settle);
244
348
  }
245
349
  catch (error) {
246
- json(response, 500, { error: error instanceof Error ? error.message : String(error) });
350
+ if (error instanceof HttpError) {
351
+ json(response, error.status, { error: error.publicMessage });
352
+ return;
353
+ }
354
+ console.error(error);
355
+ json(response, 500, { error: "internal server error" });
247
356
  }
248
357
  });
249
358
  }
@@ -31,8 +31,37 @@ export function getToken(network, symbol) {
31
31
  throw new Error(`unknown token ${symbol} on ${network}`);
32
32
  return token;
33
33
  }
34
+ function numberToDecimalString(amount) {
35
+ const value = amount.toString();
36
+ const match = value.match(/^(\d+(?:\.\d+)?)[eE]([+-]?\d+)$/);
37
+ if (!match)
38
+ return value;
39
+ const [, coefficient, exponentText] = match;
40
+ const exponent = Number(exponentText);
41
+ const [whole, fraction = ""] = coefficient.split(".");
42
+ const digits = whole + fraction;
43
+ const decimalIndex = whole.length + exponent;
44
+ if (decimalIndex <= 0)
45
+ return `0.${"0".repeat(Math.abs(decimalIndex))}${digits}`.replace(/0+$/, "");
46
+ if (decimalIndex >= digits.length)
47
+ return `${digits}${"0".repeat(decimalIndex - digits.length)}`;
48
+ return `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`.replace(/0+$/, "").replace(/\.$/, "");
49
+ }
34
50
  export function toSmallestUnit(amount, decimals) {
35
- const [whole, fraction = ""] = String(amount).split(".");
51
+ if (!Number.isInteger(decimals) || decimals < 0)
52
+ throw new Error(`invalid token decimals ${decimals}`);
53
+ const numeric = typeof amount === "number" ? amount : Number(amount);
54
+ if (!Number.isFinite(numeric) || numeric < 0)
55
+ throw new Error(`invalid amount ${amount}`);
56
+ const value = typeof amount === "number" ? numberToDecimalString(amount) : String(amount);
57
+ if (!/^\d+(\.\d+)?$/.test(value))
58
+ throw new Error(`invalid decimal amount ${amount}`);
59
+ const [whole, fraction = ""] = value.split(".");
36
60
  const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
37
- return (BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString();
61
+ let units = BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0");
62
+ if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals)))
63
+ units += 1n;
64
+ if (numeric > 0 && units === 0n)
65
+ units = 1n;
66
+ return units.toString();
38
67
  }
package/dist/tokens.js CHANGED
@@ -92,8 +92,19 @@ export function findTokenByAddress(network, address) {
92
92
  const lower = address.toLowerCase();
93
93
  return Object.values(TOKENS[normalizeNetwork(network)] ?? {}).find(token => token.address.toLowerCase() === lower);
94
94
  }
95
+ export function assertRawAmount(value, name = "raw amount") {
96
+ if (!/^\d+$/.test(value))
97
+ throw new Error(`${name} must be a non-negative integer string`);
98
+ return value.replace(/^0+(?=\d)/, "");
99
+ }
95
100
  export function toSmallestUnit(amount, decimals) {
101
+ if (!Number.isInteger(decimals) || decimals < 0)
102
+ throw new Error("token decimals must be a non-negative integer");
103
+ if (!/^\d+(\.\d+)?$/.test(amount))
104
+ throw new Error("amount must be a non-negative decimal string");
96
105
  const [whole, fraction = ""] = amount.split(".");
106
+ if (fraction.length > decimals)
107
+ throw new Error(`amount has more than ${decimals} decimal places`);
97
108
  const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
98
- return (BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString();
109
+ return assertRawAmount((BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString());
99
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bankofai/x402-cli",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -22,9 +22,10 @@
22
22
  "dependencies": {
23
23
  "@bankofai/x402-core": "1.0.0",
24
24
  "@bankofai/x402-evm": "1.0.0",
25
+ "@bankofai/x402-gateway": "^1.0.0-beta.1",
25
26
  "@bankofai/x402-tron": "1.0.0",
26
- "tronweb": "^6.0.4",
27
- "viem": "^2.40.3",
27
+ "tronweb": "6.4.0",
28
+ "viem": "^2.55.0",
28
29
  "yaml": "^2.8.2"
29
30
  },
30
31
  "devDependencies": {