@bankofai/x402-cli 1.0.1-beta.7 → 1.0.1-beta.9
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/args.js +96 -0
- package/dist/catalog-commands.js +558 -0
- package/dist/cli.js +49 -1372
- package/dist/daemon.js +47 -0
- package/dist/gateway/catalog.js +56 -0
- package/dist/gateway/cli.js +0 -0
- package/dist/gateway/config.js +48 -3
- package/dist/gateway/index.js +3 -0
- package/dist/gateway/server.js +95 -18
- 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/provider-config.js +94 -0
- package/dist/tokens.js +2 -2
- package/dist/x402.js +8 -2
- package/package.json +4 -4
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/cli.js
CHANGED
|
File without changes
|
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,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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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)
|
|
@@ -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,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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { opt, outputMode } from "./args.js";
|
|
7
|
+
import { emit, printJson } from "./output.js";
|
|
8
|
+
import { buildGatewayCatalog, loadProviders, providerPaymentAssets } from "@bankofai/x402-gateway";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
function executableInPath(name) {
|
|
11
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
12
|
+
if (!dir)
|
|
13
|
+
continue;
|
|
14
|
+
const candidate = path.join(dir, name);
|
|
15
|
+
try {
|
|
16
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Try the next PATH entry.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function resolveGatewayPackageRuntime() {
|
|
26
|
+
try {
|
|
27
|
+
return path.join(path.dirname(require.resolve("@bankofai/x402-gateway/package.json")), "dist", "cli.js");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function gatewayCommand(options) {
|
|
34
|
+
const explicit = opt(options, "gateway-bin");
|
|
35
|
+
const gatewayPackageRuntime = resolveGatewayPackageRuntime();
|
|
36
|
+
const candidates = [
|
|
37
|
+
explicit ? { file: explicit, source: "--gateway-bin" } : undefined,
|
|
38
|
+
gatewayPackageRuntime ? { file: gatewayPackageRuntime, source: "@bankofai/x402-gateway dependency" } : undefined,
|
|
39
|
+
{ file: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "gateway", "cli.js"), source: "bundled gateway runtime" },
|
|
40
|
+
executableInPath("x402-gateway") ? { file: executableInPath("x402-gateway"), source: "PATH x402-gateway" } : undefined,
|
|
41
|
+
{ file: path.resolve(process.cwd(), "../x402-gateway/dist/cli.js"), source: "sibling ../x402-gateway" },
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
try {
|
|
45
|
+
fs.accessSync(candidate.file, fs.constants.R_OK);
|
|
46
|
+
if (candidate.file.endsWith(".js")) {
|
|
47
|
+
return { command: process.execPath, argsPrefix: [candidate.file], source: candidate.source };
|
|
48
|
+
}
|
|
49
|
+
return { command: candidate.file, argsPrefix: [], source: candidate.source };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Try the next candidate.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
throw new Error("x402-gateway runtime not found. Install @bankofai/x402-gateway, run from a checkout with ../x402-gateway/dist/cli.js, or pass --gateway-bin <path>.");
|
|
56
|
+
}
|
|
57
|
+
export async function gatewayStart(args, options) {
|
|
58
|
+
const gateway = gatewayCommand(options);
|
|
59
|
+
const child = spawn(gateway.command, [...gateway.argsPrefix, ...args], {
|
|
60
|
+
stdio: "inherit",
|
|
61
|
+
env: process.env,
|
|
62
|
+
});
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`gateway exited with ${code}`)));
|
|
65
|
+
child.on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function gatewayCheck(target, options) {
|
|
69
|
+
const providers = loadProviders(target);
|
|
70
|
+
emit({
|
|
71
|
+
command: "gateway check",
|
|
72
|
+
mode: outputMode(options),
|
|
73
|
+
result: { providers: [...providers.keys()], count: providers.size },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function gatewayScaffold(name, options) {
|
|
77
|
+
const outputDir = opt(options, "output-dir", path.join("providers", name));
|
|
78
|
+
const forwardUrl = opt(options, "forward-url", "https://api.example.com");
|
|
79
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
80
|
+
const body = `name: ${name}
|
|
81
|
+
title: "${name}"
|
|
82
|
+
description: "x402 provider"
|
|
83
|
+
category: data
|
|
84
|
+
version: v1
|
|
85
|
+
|
|
86
|
+
forward_url: ${forwardUrl}
|
|
87
|
+
|
|
88
|
+
routing:
|
|
89
|
+
type: proxy
|
|
90
|
+
|
|
91
|
+
operator:
|
|
92
|
+
network: tron:0xcd8690dc
|
|
93
|
+
currencies:
|
|
94
|
+
usd: ["USDT"]
|
|
95
|
+
recipient: <provider-recipient-address>
|
|
96
|
+
scheme: exact
|
|
97
|
+
protocol: exact
|
|
98
|
+
asset_transfer_method: permit2
|
|
99
|
+
facilitator_url: https://facilitator.bankofai.io
|
|
100
|
+
facilitator_api_key: <facilitator-api-key>
|
|
101
|
+
valid_for_seconds: 300
|
|
102
|
+
|
|
103
|
+
endpoints:
|
|
104
|
+
- method: GET
|
|
105
|
+
path: /v1/ping
|
|
106
|
+
metering:
|
|
107
|
+
dimensions:
|
|
108
|
+
- tiers:
|
|
109
|
+
- price_usd: 0.0001
|
|
110
|
+
`;
|
|
111
|
+
fs.writeFileSync(path.join(outputDir, "provider.yml"), body);
|
|
112
|
+
emit({
|
|
113
|
+
command: "gateway scaffold",
|
|
114
|
+
mode: outputMode(options),
|
|
115
|
+
result: { file: path.join(outputDir, "provider.yml") },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export function catalogBuild(target, options) {
|
|
119
|
+
const providers = loadProviders(target);
|
|
120
|
+
const catalog = buildGatewayCatalog(providers);
|
|
121
|
+
const output = opt(options, "output", opt(options, "dist-dir") ? path.join(opt(options, "dist-dir"), "catalog.json") : undefined);
|
|
122
|
+
if (output) {
|
|
123
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
124
|
+
fs.writeFileSync(output, JSON.stringify(catalog, null, 2));
|
|
125
|
+
emit({
|
|
126
|
+
command: "catalog build",
|
|
127
|
+
mode: outputMode(options),
|
|
128
|
+
result: { output, count: providers.size },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else if (outputMode(options) === "json") {
|
|
132
|
+
emit({ command: "catalog build", mode: "json", result: catalog });
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
printJson(catalog);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function catalogPayAssets(target, options) {
|
|
139
|
+
const rows = providerPaymentAssets(loadProviders(target));
|
|
140
|
+
emit({
|
|
141
|
+
command: "gateway catalog pay-assets",
|
|
142
|
+
mode: outputMode(options),
|
|
143
|
+
result: { count: rows.length, assets: rows },
|
|
144
|
+
});
|
|
145
|
+
}
|