@bankofai/x402-gateway 1.0.0-beta.0 → 1.0.0-beta.2
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 +91 -4
- package/dist/cli.js +245 -15
- package/dist/config.js +69 -8
- package/dist/server.js +132 -23
- package/dist/tokens.js +31 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -17,18 +17,43 @@ npm install
|
|
|
17
17
|
npm run build
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
After installing the npm package globally, use the binary directly:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @bankofai/x402-gateway
|
|
24
|
+
x402-gateway --help
|
|
25
|
+
```
|
|
26
|
+
|
|
20
27
|
## Run
|
|
21
28
|
|
|
22
29
|
Start from a single provider file:
|
|
23
30
|
|
|
24
31
|
```bash
|
|
25
|
-
|
|
32
|
+
x402-gateway --provider examples/provider.yml --host 127.0.0.1 --port 4020
|
|
26
33
|
```
|
|
27
34
|
|
|
28
35
|
Start from a providers directory:
|
|
29
36
|
|
|
30
37
|
```bash
|
|
31
|
-
|
|
38
|
+
x402-gateway --providers providers --host 127.0.0.1 --port 4020
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Validate provider files without starting the server:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
x402-gateway check --providers providers
|
|
45
|
+
x402-gateway check --provider examples/provider.yml --json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`check` validates provider YAML syntax, required fields, environment expansion,
|
|
49
|
+
network aliases, endpoint shape, and duplicate provider names. It does not call
|
|
50
|
+
upstream APIs, facilitator services, RPC endpoints, recipient addresses, or the
|
|
51
|
+
full payment path.
|
|
52
|
+
|
|
53
|
+
Run from source during development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm run dev -- --providers providers --host 127.0.0.1 --port 4020
|
|
32
57
|
```
|
|
33
58
|
|
|
34
59
|
Health check:
|
|
@@ -48,6 +73,45 @@ If the endpoint has metering, the gateway returns `402 Payment Required` with a
|
|
|
48
73
|
the gateway verifies and settles with the facilitator, then forwards to the
|
|
49
74
|
configured upstream.
|
|
50
75
|
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
x402-gateway start --providers <dir> [options]
|
|
80
|
+
x402-gateway --providers <dir> [options]
|
|
81
|
+
x402-gateway check --providers <dir>
|
|
82
|
+
x402-gateway check --provider <file>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
|
|
87
|
+
- `--provider <file>`: load one provider YAML file.
|
|
88
|
+
- `--providers <dir>`: recursively load `provider.yml` / `provider.yaml`.
|
|
89
|
+
`--provider` and `--providers` are mutually exclusive.
|
|
90
|
+
- `--host <host>`: bind host. The CLI default is `127.0.0.1`; Docker passes `0.0.0.0`.
|
|
91
|
+
- `--port <port>`: bind port, default `8080`.
|
|
92
|
+
- `--json`: machine-readable startup/check/error output.
|
|
93
|
+
- `--quiet`: suppress startup, shutdown, and successful `check` output.
|
|
94
|
+
- `--debug`: include stack traces for startup errors.
|
|
95
|
+
- `--help`, `--version`: inspect usage and installed version.
|
|
96
|
+
|
|
97
|
+
Startup prints human-readable URLs by default:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
x402-gateway listening on http://127.0.0.1:4020
|
|
101
|
+
providers: 14 loaded
|
|
102
|
+
health: http://127.0.0.1:4020/__402/health
|
|
103
|
+
ready: http://127.0.0.1:4020/__402/ready
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
With `--json`, startup output includes `source`, `host`, `port`, `count`,
|
|
107
|
+
`providers`, `health`, and `ready` for deployment scripts. CLI errors also
|
|
108
|
+
honor `--json`, including argument parsing errors such as unknown options.
|
|
109
|
+
|
|
110
|
+
Admin endpoints such as `/__402/providers`, `/__402/endpoints`, and `/metrics`
|
|
111
|
+
are protected by default. Set `X402_GATEWAY_ADMIN_TOKEN` in deployed
|
|
112
|
+
environments and call them with `Authorization: Bearer <token>`. For a
|
|
113
|
+
deliberately public test deployment, set `X402_GATEWAY_ADMIN_ALLOW_PUBLIC=true`.
|
|
114
|
+
|
|
51
115
|
## Provider Config
|
|
52
116
|
|
|
53
117
|
Provider files stay in YAML:
|
|
@@ -99,9 +163,32 @@ Network aliases accepted:
|
|
|
99
163
|
## Environment
|
|
100
164
|
|
|
101
165
|
```bash
|
|
102
|
-
|
|
103
|
-
|
|
166
|
+
X402_GATEWAY_PROVIDERS_DIR=providers
|
|
167
|
+
X402_GATEWAY_HOST=127.0.0.1
|
|
168
|
+
PORT=8080
|
|
169
|
+
X402_GATEWAY_ADMIN_TOKEN=<admin-token>
|
|
170
|
+
X402_FACILITATOR_URL=https://facilitator-v2.bankofai.io
|
|
171
|
+
X402_FACILITATOR_API_KEY=<facilitator-api-key>
|
|
104
172
|
X402_PROVIDER_FORWARD_URL=<upstream-base-url>
|
|
105
173
|
X402_PROVIDER_RECIPIENT_TRON=<recipient-T-address>
|
|
106
174
|
X402_PROVIDER_API_TOKEN=<upstream-token>
|
|
107
175
|
```
|
|
176
|
+
|
|
177
|
+
Provider YAML may also use `operator.facilitator_api_key_env:
|
|
178
|
+
X402_FACILITATOR_API_KEY` so deployments can inject the facilitator API key via
|
|
179
|
+
environment variable without storing it in the mounted provider file.
|
|
180
|
+
|
|
181
|
+
## Docker
|
|
182
|
+
|
|
183
|
+
The container runs the same CLI binary:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
docker run --rm -p 4020:8080 \
|
|
187
|
+
-v "$PWD/providers:/app/providers:ro" \
|
|
188
|
+
-e X402_GATEWAY_ADMIN_TOKEN=<admin-token> \
|
|
189
|
+
-e X402_FACILITATOR_API_KEY=<facilitator-api-key> \
|
|
190
|
+
bankofai/x402-gateway:<tag>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The Docker command binds `0.0.0.0:8080` explicitly; local CLI runs default to
|
|
194
|
+
`127.0.0.1` unless `--host` or `X402_GATEWAY_HOST` is set.
|
package/dist/cli.js
CHANGED
|
@@ -1,33 +1,263 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (item === "-v" || item === "-V") {
|
|
30
|
+
options.version = true;
|
|
9
31
|
continue;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
function flag(options, key) {
|
|
66
|
+
return options[key] === true;
|
|
67
|
+
}
|
|
68
|
+
function rawHasFlag(argv, name, short) {
|
|
69
|
+
return argv.some(item => item === `--${name}` || item.startsWith(`--${name}=`) || (short ? item === short : false));
|
|
70
|
+
}
|
|
71
|
+
function readVersion() {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL("../package.json", import.meta.url);
|
|
74
|
+
const version = JSON.parse(fs.readFileSync(url, "utf8")).version;
|
|
75
|
+
return typeof version === "string" && version ? version : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function help() {
|
|
82
|
+
return `x402-gateway ${readVersion() ?? "unknown"}
|
|
83
|
+
|
|
84
|
+
Usage:
|
|
85
|
+
x402-gateway start --providers <dir> [options]
|
|
86
|
+
x402-gateway --providers <dir> [options]
|
|
87
|
+
x402-gateway check --providers <dir>
|
|
88
|
+
x402-gateway check --provider <file>
|
|
89
|
+
|
|
90
|
+
Commands:
|
|
91
|
+
start Start the x402 gateway server (default)
|
|
92
|
+
check Validate provider YAML and print a summary
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
--provider <file> Load one provider YAML file
|
|
96
|
+
--providers <dir> Load provider.yml/provider.yaml files from a directory
|
|
97
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
98
|
+
--port <port> Bind port (default: 8080)
|
|
99
|
+
--json Print machine-readable JSON
|
|
100
|
+
--quiet Suppress startup/shutdown messages
|
|
101
|
+
--debug Print stack traces for startup errors
|
|
102
|
+
-h, --help Show help
|
|
103
|
+
-v, -V, --version Show version
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
x402-gateway --provider examples/provider.yml --host 127.0.0.1 --port 4020
|
|
107
|
+
x402-gateway start --providers providers --port 4020
|
|
108
|
+
x402-gateway check --providers providers --json
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
function providerPath(options) {
|
|
112
|
+
if (opt(options, "provider") && opt(options, "providers")) {
|
|
113
|
+
throw new CliError("Options --provider and --providers are mutually exclusive.");
|
|
114
|
+
}
|
|
115
|
+
const source = opt(options, "providers", opt(options, "provider", process.env.X402_GATEWAY_PROVIDERS_DIR));
|
|
116
|
+
if (!source)
|
|
117
|
+
throw new CliError("No provider source configured. Use --provider <file> or --providers <dir>.");
|
|
118
|
+
return source;
|
|
119
|
+
}
|
|
120
|
+
function parsePort(value) {
|
|
121
|
+
const raw = value ?? process.env.PORT ?? "8080";
|
|
122
|
+
if (!/^\d+$/.test(raw))
|
|
123
|
+
throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
|
|
124
|
+
const port = Number(raw);
|
|
125
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
126
|
+
throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
|
|
127
|
+
}
|
|
128
|
+
return port;
|
|
129
|
+
}
|
|
130
|
+
function parseHost(value) {
|
|
131
|
+
const host = value ?? process.env.X402_GATEWAY_HOST ?? "127.0.0.1";
|
|
132
|
+
if (!host.trim())
|
|
133
|
+
throw new CliError("Invalid --host; expected a non-empty host.");
|
|
134
|
+
return host;
|
|
135
|
+
}
|
|
136
|
+
function listenErrorMessage(error, host, port) {
|
|
137
|
+
const err = error;
|
|
138
|
+
if (err?.code === "EADDRINUSE")
|
|
139
|
+
return `Port ${port} is already in use on ${host}. Choose another --port or stop the existing process.`;
|
|
140
|
+
if (err?.code === "EADDRNOTAVAIL")
|
|
141
|
+
return `Host ${host} is not available on this machine. Use --host 127.0.0.1 for local runs or --host 0.0.0.0 in containers.`;
|
|
142
|
+
return error instanceof Error ? error.message : String(error);
|
|
143
|
+
}
|
|
144
|
+
function publicHost(host) {
|
|
145
|
+
return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
146
|
+
}
|
|
147
|
+
function localUrls(host, port) {
|
|
148
|
+
const base = `http://${publicHost(host)}:${port}`;
|
|
149
|
+
return {
|
|
150
|
+
base,
|
|
151
|
+
health: `${base}/__402/health`,
|
|
152
|
+
ready: `${base}/__402/ready`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function printStartup(options, source, host, port, providers) {
|
|
156
|
+
if (flag(options, "quiet"))
|
|
157
|
+
return;
|
|
158
|
+
const urls = localUrls(host, port);
|
|
159
|
+
if (flag(options, "json")) {
|
|
160
|
+
process.stdout.write(JSON.stringify({ ok: true, source, host, port, count: providers.length, providers, health: urls.health, ready: urls.ready }, null, 2) + "\n");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
process.stdout.write(`x402-gateway listening on ${urls.base}\n`);
|
|
164
|
+
process.stdout.write(`providers: ${providers.length} loaded\n`);
|
|
165
|
+
process.stdout.write(`health: ${urls.health}\n`);
|
|
166
|
+
process.stdout.write(`ready: ${urls.ready}\n`);
|
|
167
|
+
}
|
|
168
|
+
function loadProvidersForCli(action, source) {
|
|
169
|
+
try {
|
|
170
|
+
return loadProviders(source);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
throw new Error(`failed to load providers from ${source} for ${action}: ${message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function check(options) {
|
|
178
|
+
const source = providerPath(options);
|
|
179
|
+
const providers = loadProvidersForCli("check", source);
|
|
180
|
+
const summary = [...providers.values()].map(entry => ({
|
|
181
|
+
name: entry.config.name,
|
|
182
|
+
network: entry.config.operator.network,
|
|
183
|
+
endpoints: entry.config.endpoints?.length ?? 0,
|
|
184
|
+
}));
|
|
185
|
+
if (flag(options, "json")) {
|
|
186
|
+
process.stdout.write(JSON.stringify({ ok: true, source, count: summary.length, providers: summary }, null, 2) + "\n");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (flag(options, "quiet"))
|
|
190
|
+
return;
|
|
191
|
+
process.stdout.write(`ok: ${summary.length} provider${summary.length === 1 ? "" : "s"} loaded from ${source}\n`);
|
|
192
|
+
for (const item of summary) {
|
|
193
|
+
process.stdout.write(` ${item.name} (${item.network}) endpoints=${item.endpoints}\n`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function start(options) {
|
|
197
|
+
const source = providerPath(options);
|
|
198
|
+
const host = parseHost(opt(options, "host"));
|
|
199
|
+
const port = parsePort(opt(options, "port"));
|
|
200
|
+
const providers = loadProvidersForCli("start", source);
|
|
201
|
+
const server = createGatewayServer(providers);
|
|
202
|
+
await new Promise((resolve, reject) => {
|
|
203
|
+
server.once("error", error => reject(new Error(listenErrorMessage(error, host, port))));
|
|
204
|
+
server.listen(port, host, () => {
|
|
205
|
+
server.off("error", reject);
|
|
206
|
+
printStartup(options, source, host, port, [...providers.keys()]);
|
|
207
|
+
resolve();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
const shutdown = (signal) => {
|
|
211
|
+
if (!flag(options, "quiet") && !flag(options, "json"))
|
|
212
|
+
process.stderr.write(`received ${signal}, shutting down...\n`);
|
|
213
|
+
const timeout = setTimeout(() => {
|
|
214
|
+
if (!flag(options, "quiet") && !flag(options, "json"))
|
|
215
|
+
process.stderr.write("server close timed out; exiting\n");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}, 10_000);
|
|
218
|
+
timeout.unref();
|
|
219
|
+
server.close(() => {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
if (!flag(options, "quiet") && !flag(options, "json"))
|
|
222
|
+
process.stderr.write("server closed\n");
|
|
223
|
+
process.exit(0);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
process.once("SIGINT", shutdown);
|
|
227
|
+
process.once("SIGTERM", shutdown);
|
|
228
|
+
}
|
|
229
|
+
async function main() {
|
|
230
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
231
|
+
if (flag(parsed.options, "help")) {
|
|
232
|
+
process.stdout.write(help());
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (flag(parsed.options, "version")) {
|
|
236
|
+
const version = readVersion();
|
|
237
|
+
if (!version)
|
|
238
|
+
throw new Error("Unable to read package version.");
|
|
239
|
+
process.stdout.write(`${version}\n`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (parsed.command === "check")
|
|
243
|
+
check(parsed.options);
|
|
244
|
+
else
|
|
245
|
+
await start(parsed.options);
|
|
246
|
+
}
|
|
247
|
+
main().catch(error => {
|
|
248
|
+
const argv = process.argv.slice(2);
|
|
249
|
+
const json = rawHasFlag(argv, "json");
|
|
250
|
+
const debug = rawHasFlag(argv, "debug");
|
|
251
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
252
|
+
if (json) {
|
|
253
|
+
process.stdout.write(JSON.stringify({ ok: false, error: { message } }, null, 2) + "\n");
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
process.stderr.write(`x402-gateway: ${message}\n`);
|
|
257
|
+
process.stderr.write("Run `x402-gateway --help` for usage.\n");
|
|
258
|
+
if (debug && error instanceof Error && error.stack) {
|
|
259
|
+
process.stderr.write(`${error.stack}\n`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
process.exit(error instanceof CliError ? error.exitCode : 1);
|
|
33
263
|
});
|
package/dist/config.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
+
amount,
|
|
130
191
|
asset: token.address,
|
|
131
192
|
payTo,
|
|
132
193
|
maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300,
|
package/dist/server.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
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
|
|
325
|
+
if (!isVerifySuccess(verify)) {
|
|
227
326
|
metrics.verifyFailures += 1;
|
|
228
|
-
json(response, 400, { error: "payment verification failed"
|
|
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
|
-
|
|
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
|
}
|
package/dist/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bankofai/x402-gateway",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"bin": {
|
|
13
|
-
"x402-gateway": "
|
|
13
|
+
"x402-gateway": "dist/cli.js"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
17
18
|
"start": "node dist/cli.js",
|
|
18
19
|
"dev": "tsx src/cli.ts"
|
|
19
20
|
},
|