@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/README.md +56 -20
- package/dist/args.js +96 -0
- package/dist/catalog-commands.js +558 -0
- package/dist/cli.js +104 -1372
- package/dist/daemon.js +47 -0
- package/dist/gateway/catalog.js +56 -0
- package/dist/gateway/config.js +60 -9
- package/dist/gateway/index.js +3 -0
- package/dist/gateway/server.js +171 -31
- package/dist/gateway/tokens.js +11 -5
- package/dist/gateway-commands.js +145 -0
- package/dist/help.js +169 -0
- package/dist/http-client.js +82 -0
- package/dist/output.js +91 -0
- package/dist/tokens.js +13 -7
- package/dist/x402.js +25 -11
- package/package.json +12 -8
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
|
+
}
|
package/dist/gateway/config.js
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
115
|
-
? process.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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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";
|
package/dist/gateway/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
310
|
+
response.end(responseBody);
|
|
221
311
|
}
|
|
222
312
|
export function createGatewayServer(providers) {
|
|
223
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/gateway/tokens.js
CHANGED
|
@@ -16,19 +16,25 @@ export const TOKENS = {
|
|
|
16
16
|
},
|
|
17
17
|
};
|
|
18
18
|
export function normalizeNetwork(network) {
|
|
19
|
-
|
|
19
|
+
const legacyTronIds = {
|
|
20
20
|
"tron-mainnet": "tron:0x2b6653dc",
|
|
21
21
|
"tron:mainnet": "tron:0x2b6653dc",
|
|
22
|
-
|
|
22
|
+
mainnet: "tron:0x2b6653dc",
|
|
23
23
|
"tron-shasta": "tron:0x94a9059e",
|
|
24
24
|
"tron:shasta": "tron:0x94a9059e",
|
|
25
|
-
|
|
25
|
+
shasta: "tron:0x94a9059e",
|
|
26
26
|
"tron-nile": "tron:0xcd8690dc",
|
|
27
27
|
"tron:nile": "tron:0xcd8690dc",
|
|
28
|
-
|
|
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);
|