diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f088e8..5ba8290 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: - name: Build run: npm run build + - name: Test + run: npm test + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + - name: Validate compose file run: docker compose config diff --git a/Dockerfile b/Dockerfile index ebdd9a0..2a357e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-slim +FROM node:20-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 ENV HOME=/home/node diff --git a/README.md b/README.md index 5340137..940d65b 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ TypeScript reverse proxy for paid HTTP APIs. This version uses the npm TypeScript x402 SDK packages only: -- `@bankofai/x402-core@1.0.0` -- `@bankofai/x402-evm@1.0.0` -- `@bankofai/x402-tron@1.0.0` +- `@bankofai/x402-core@1.0.1` +- `@bankofai/x402-evm@1.0.1` +- `@bankofai/x402-tron@1.0.1` -Payment requirements are emitted as `scheme=exact`; supported stablecoins add -`extra.assetTransferMethod=permit2`. +Payment requirements support `scheme=exact` and TRON `scheme=exact_gasfree`. +Exact requirements add `extra.assetTransferMethod=permit2`; GasFree requirements +use the TRON GasFree relayer flow without Permit2 metadata. ## Install @@ -20,7 +21,7 @@ npm run build After installing the npm package globally, use the binary directly: ```bash -npm install -g @bankofai/x402-gateway +npm install -g @bankofai/x402-gateway@beta x402-gateway --help ``` @@ -65,7 +66,7 @@ curl http://127.0.0.1:4020/__402/health Paid provider path: ```bash -curl -i http://127.0.0.1:4020/providers/tron-nile-usdt/v1/ping +curl -i http://127.0.0.1:4020/providers/example-price-tron/v1/ping ``` If the endpoint has metering, the gateway returns `402 Payment Required` with a @@ -117,7 +118,7 @@ deliberately public test deployment, set `X402_GATEWAY_ADMIN_ALLOW_PUBLIC=true`. Provider files stay in YAML: ```yaml -name: tron-nile-usdt +name: example-price-tron forward_url: ${X402_PROVIDER_FORWARD_URL} routing: @@ -128,7 +129,7 @@ routing: value_from_env: X402_PROVIDER_API_TOKEN operator: - network: tron-nile + network: tron:0xcd8690dc currencies: usd: ["USDT"] recipient: ${X402_PROVIDER_RECIPIENT_TRON} @@ -148,15 +149,17 @@ endpoints: - price_usd: 0.002 ``` -`@bankofai/x402-*` 1.0.0 uses `scheme: exact` with -`extra.assetTransferMethod: permit2` in the payment requirement. Older provider -configs that say `exact_permit` are normalized at load time, but new provider -configs should use `protocol: exact` and `asset_transfer_method: permit2`. +`@bankofai/x402-*` 1.0.1 uses `scheme: exact` with +`extra.assetTransferMethod: permit2`, or TRON `scheme: exact_gasfree`. Older +provider configs that say `exact_permit` are normalized to `exact`. For GasFree, +set both `scheme` and `protocol` to `exact_gasfree`; the facilitator must support +GasFree for the selected TRON network and token. -Network aliases accepted: +Non-CAIP TRON aliases are rejected. Provider files must use canonical TRON +CAIP-2 IDs. + +EVM convenience aliases accepted: -- `tron-mainnet` -> `tron:mainnet` -- `tron-nile` -> `tron:nile` - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` @@ -167,6 +170,13 @@ X402_GATEWAY_PROVIDERS_DIR=providers X402_GATEWAY_HOST=127.0.0.1 PORT=8080 X402_GATEWAY_ADMIN_TOKEN= +X402_GATEWAY_PUBLIC_BASE_URL=https://gateway.example.com +X402_GATEWAY_MAX_BODY_BYTES=1000000 +X402_GATEWAY_MAX_RESPONSE_BYTES=10000000 +X402_GATEWAY_FACILITATOR_TIMEOUT_MS=10000 +X402_GATEWAY_UPSTREAM_TIMEOUT_MS=30000 +X402_GATEWAY_MAX_CONCURRENT_REQUESTS=100 +X402_GATEWAY_RATE_LIMIT_PER_MINUTE=300 X402_FACILITATOR_URL=https://facilitator-v2.bankofai.io X402_FACILITATOR_API_KEY= X402_PROVIDER_FORWARD_URL= @@ -177,6 +187,13 @@ X402_PROVIDER_API_TOKEN= Provider YAML may also use `operator.facilitator_api_key_env: X402_FACILITATOR_API_KEY` so deployments can inject the facilitator API key via environment variable without storing it in the mounted provider file. +When `facilitator_api_key_env` is configured, that named variable is required; +`check` and `start` fail instead of silently contacting the facilitator without +authentication. + +`X402_GATEWAY_PUBLIC_BASE_URL` must be the externally reachable gateway origin. +It makes the challenge `resource.url` absolute, which is required when the +container's internal host or request path is not the public payment URL. ## Docker @@ -187,7 +204,7 @@ docker run --rm -p 4020:8080 \ -v "$PWD/providers:/app/providers:ro" \ -e X402_GATEWAY_ADMIN_TOKEN= \ -e X402_FACILITATOR_API_KEY= \ - bankofai/x402-gateway:v20260709182145 + ``` The Docker command binds `0.0.0.0:8080` explicitly; local CLI runs default to diff --git a/docker-compose.yml b/docker-compose.yml index 9f5d15b..643ef45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: gateway: - image: ${X402_GATEWAY_IMAGE:-bankofai/x402-gateway:v20260709182145} + image: ${X402_GATEWAY_IMAGE:-bankofai/x402-gateway:test} + pull_policy: always build: context: . dockerfile: Dockerfile @@ -12,6 +13,8 @@ services: environment: X402_GATEWAY_PROVIDERS_DIR: ${X402_GATEWAY_PROVIDERS_DIR:-/app/providers} X402_GATEWAY_PUBLIC_BASE_URL: ${X402_GATEWAY_PUBLIC_BASE_URL:-http://host.docker.internal:4020} + X402_GATEWAY_MAX_CONCURRENT_REQUESTS: ${X402_GATEWAY_MAX_CONCURRENT_REQUESTS:-100} + X402_GATEWAY_RATE_LIMIT_PER_MINUTE: ${X402_GATEWAY_RATE_LIMIT_PER_MINUTE:-300} X402_FACILITATOR_URL: ${X402_FACILITATOR_URL:-https://facilitator.example.com} X402_FACILITATOR_API_KEY: ${X402_FACILITATOR_API_KEY:-} X402_PROVIDER_RECIPIENT_TRON: ${X402_PROVIDER_RECIPIENT_TRON:-} diff --git a/examples/provider.yml b/examples/provider.yml index bfeb0bc..eacf898 100644 --- a/examples/provider.yml +++ b/examples/provider.yml @@ -1,4 +1,4 @@ -name: tron-nile-usdt +name: example-price-tron title: "TRON Nile USDT Provider" description: "TRON Nile provider protected by x402 exact Permit2 USDT payment" category: data @@ -8,8 +8,8 @@ forward_url: ${X402_PROVIDER_FORWARD_URL} openapi_url: ${X402_PROVIDER_OPENAPI_URL} display: - service_url: ${X402_GATEWAY_PUBLIC_BASE_URL}/providers/tron-nile-usdt - tags: ["tron-nile", "usdt", "x402"] + service_url: ${X402_GATEWAY_PUBLIC_BASE_URL}/providers/example-price-tron + tags: ["nile", "usdt", "x402"] discovery: use_case: "Use for validating TRON Nile USDT x402 payments." @@ -18,7 +18,7 @@ discovery: when_to_use: - "Use when testing x402 exact Permit2 payments on TRON Nile." request_examples: - - "GET /providers/tron-nile-usdt/v1/ping" + - "GET /providers/example-price-tron/v1/ping" routing: type: proxy @@ -29,7 +29,7 @@ routing: value_from_env: X402_PROVIDER_API_TOKEN operator: - network: tron-nile + network: tron:0xcd8690dc currencies: usd: ["USDT"] recipient: ${X402_PROVIDER_RECIPIENT_TRON} diff --git a/package-lock.json b/package-lock.json index e11d640..1f95b6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-gateway", - "version": "1.0.0", + "version": "1.0.1-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-gateway", - "version": "1.0.0", + "version": "1.0.1-beta.7", "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "yaml": "^2.8.2" }, "bin": { @@ -44,32 +44,32 @@ } }, "node_modules/@bankofai/x402-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.0.tgz", - "integrity": "sha512-uVvvXCGfk/HqusxcKmjeTgBlJBMTR7QPZH2IDZlqoliv5O36YASaHSgSo/JOKDvVzPoXlY+5VmxEG6EdWA+wGA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1.tgz", + "integrity": "sha512-2D4W1dTIlaHFrK+C6tazk12iERAcofHMHwG9qFuUhC4Dubhma4YJfpDsbbXQm5QXOsHDMOCTItpdp7nvZoLCeQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-evm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.0.tgz", - "integrity": "sha512-j0T88Y5ngItIS9FTWXtbvoITePISzhuurU640dDL9075uQxBiLzYt20rkPebM7gi9D00HTbyJXbHnCq0zajCBQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1.tgz", + "integrity": "sha512-4JSElA0vdxdDhkqHfXMT0Q3QH7II7CrHfct8ZV714N42PrxCWdGglHSsSeC2ZFBdracozBZyFxPWZKDSy6CTxg==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-tron": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.0.tgz", - "integrity": "sha512-ycq+I1jMFLoQdMP8AzgXEUkSjzQFPrMkRAehcfiFACzrtAciq8Ev3uB0N8+2TXIC55l9fcTxrm+EB/C75T9xVg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1.tgz", + "integrity": "sha512-dsjoOSOJ97dk/o8vpOPJ/FIYAJt2N06SonN9H51WwKdqGoiK5w5fz0n5P36drny/d+Zvtxsy7EMI/g09pRxJUA==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "tronweb": "^6.1.0" } }, @@ -1119,9 +1119,9 @@ "license": "MIT" }, "node_modules/ox": { - "version": "0.14.29", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", - "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", "funding": [ { "type": "github", @@ -1317,9 +1317,9 @@ } }, "node_modules/viem": { - "version": "2.54.1", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.54.1.tgz", - "integrity": "sha512-QRC3GBSnQit4pbb0sSBHRD9WYTPAffvhOqdqp8LgkRRktXZq8dePezZM/ZIkFwluLT7fMP90908goHQbtcga2A==", + "version": "2.55.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.2.tgz", + "integrity": "sha512-XlJeyNAZ96dQfOHlxLTK1FKgtWw/TtxENKNMBSBgxqALjiWiBWrFmSSzwwMivryKnBwkbt5E+90jSCLnVEilLA==", "funding": [ { "type": "github", @@ -1334,8 +1334,8 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.29", - "ws": "8.20.1" + "ox": "0.14.30", + "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" @@ -1409,31 +1409,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/viem/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index eeb51e0..8957dd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-gateway", - "version": "1.0.0", + "version": "1.0.1-beta.7", "private": false, "type": "module", "files": [ @@ -22,14 +22,17 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "yaml": "^2.8.2" }, "devDependencies": { "@types/node": "^24.10.1", "tsx": "^4.20.6", "typescript": "^5.9.3" + }, + "overrides": { + "ws": "8.21.0" } } diff --git a/providers/local-gasfree/provider.yml b/providers/local-gasfree/provider.yml new file mode 100644 index 0000000..d5cc162 --- /dev/null +++ b/providers/local-gasfree/provider.yml @@ -0,0 +1,21 @@ +name: local-gasfree +title: Local GasFree E2E Provider +forward_url: http://host.docker.internal:44100 +operator: + network: tron:0xcd8690dc + currencies: + usd: ["USDT"] + recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i + schemes: [exact, exact_gasfree] + scheme: exact + protocol: exact + asset_transfer_method: permit2 + facilitator_url: https://tn-facilitator.bankofai.io + valid_for_seconds: 300 +endpoints: + - method: GET + path: /v1/ping + metering: + dimensions: + - tiers: + - price_usd: 0.000001 diff --git a/src/config.ts b/src/config.ts index c013251..e7837b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ export type ProviderConfig = { currencies?: Record; recipient: string; scheme?: string; + schemes?: string[]; protocol?: string; asset_transfer_method?: string; assetTransferMethod?: string; @@ -79,6 +80,10 @@ function assertHttpUrl(value: string | undefined, name: string): void { try { const url = new URL(value); if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol"); + if (url.username || url.password || url.search || url.hash) throw new Error("credentials, query, and fragment are not allowed"); + if (url.protocol === "http:" && !["localhost", "127.0.0.1", "::1"].includes(url.hostname) && process.env.X402_GATEWAY_ALLOW_INSECURE_HTTP !== "true") { + throw new Error("remote HTTP is not allowed"); + } } catch { throw new Error(`${name} must be a valid http(s) URL`); } @@ -129,6 +134,11 @@ function validateProvider(config: ProviderConfig, file: string): void { if (authMethod && !["header", "query_param", "access_token", "oauth2"].includes(authMethod)) { throw new Error(`${file}: unsupported routing.auth.method ${authMethod}`); } + const auth = config.routing?.auth; + if (auth) { + if (!auth.value && !auth.value_from_env) throw new Error(`${file}: routing.auth requires value or value_from_env`); + if (auth.value_from_env && !process.env[auth.value_from_env]) throw new Error(`${file}: environment variable ${auth.value_from_env} is not set`); + } if (!config.endpoints?.length) throw new Error(`${file}: endpoints must contain at least one endpoint`); const seen = new Set(); for (const [index, endpoint] of config.endpoints.entries()) { @@ -151,17 +161,24 @@ export function loadProvider(file: string): ProviderEntry { validateProvider(config, file); config.operator.network = normalizeNetwork(config.operator.network); normalizePaymentProtocol(config, file); + validatePaymentCapabilities(config, file); + const facilitatorUrl = + config.operator.facilitator_url || + process.env.X402_FACILITATOR_URL || + process.env.FACILITATOR_URL || + "https://facilitator.bankofai.io"; + assertHttpUrl(facilitatorUrl, `${file}: facilitator URL`); + const configuredApiKeyEnv = config.operator.facilitator_api_key_env; + if (configuredApiKeyEnv && !process.env[configuredApiKeyEnv]) { + throw new Error(`${file}: environment variable ${configuredApiKeyEnv} is not set`); + } return { config, - facilitatorUrl: - config.operator.facilitator_url || - process.env.X402_FACILITATOR_URL || - process.env.FACILITATOR_URL || - "https://facilitator.bankofai.io", + facilitatorUrl, facilitatorApiKey: config.operator.facilitator_api_key || - (config.operator.facilitator_api_key_env - ? process.env[config.operator.facilitator_api_key_env] + (configuredApiKeyEnv + ? process.env[configuredApiKeyEnv] : undefined) || process.env.X402_FACILITATOR_API_KEY || process.env.FACILITATOR_API_KEY, @@ -169,15 +186,56 @@ export function loadProvider(file: string): ProviderEntry { } function normalizePaymentProtocol(config: ProviderConfig, file: string): void { - const raw = String(config.operator.protocol || config.operator.scheme || "exact").toLowerCase(); - const normalized = raw.replace(/[-:\s]/g, "_"); - if (!["exact", "exact_permit", "permit2", "exact_permit2"].includes(normalized)) { - throw new Error(`${file}: unsupported x402 protocol ${raw}; use exact + permit2`); + if (config.operator.schemes !== undefined && (!Array.isArray(config.operator.schemes) || !config.operator.schemes.length)) { + throw new Error(`${file}: operator.schemes must be a non-empty string array`); + } + const rawSchemes = config.operator.schemes ?? [config.operator.protocol || config.operator.scheme || "exact"]; + const schemes = [...new Set(rawSchemes.map(value => { + if (typeof value !== "string" || !value.trim()) throw new Error(`${file}: operator.schemes must contain non-empty strings`); + const raw = String(value).toLowerCase(); + const normalized = raw.replace(/[-:\s]/g, "_"); + if (!["exact", "exact_gasfree", "exact_permit", "permit2", "exact_permit2"].includes(normalized)) { + throw new Error(`${file}: unsupported x402 protocol ${raw}; use exact or exact_gasfree`); + } + return normalized === "exact_gasfree" ? "exact_gasfree" : "exact"; + }))]; + if (schemes.includes("exact_gasfree") && !config.operator.network.startsWith("tron:")) { + throw new Error(`${file}: exact_gasfree is supported only on TRON networks`); + } + config.operator.schemes = schemes; + config.operator.scheme = schemes[0]; + config.operator.protocol = schemes[0]; + if (schemes.includes("exact")) { + config.operator.asset_transfer_method = "permit2"; + config.operator.assetTransferMethod = "permit2"; + } else { + delete config.operator.asset_transfer_method; + delete config.operator.assetTransferMethod; + } +} + +function validatePaymentCapabilities(config: ProviderConfig, file: string): void { + const symbols = config.operator.currencies?.usd ?? ["USDT"]; + if (!Array.isArray(symbols) || !symbols.length || symbols.some(symbol => typeof symbol !== "string" || !symbol.trim())) { + throw new Error(`${file}: operator.currencies.usd must be a non-empty string array`); + } + if (new Set(symbols.map(symbol => symbol.toUpperCase())).size !== symbols.length) throw new Error(`${file}: operator.currencies.usd must not contain duplicates`); + for (const symbol of symbols) getToken(config.operator.network, symbol); + + const recipient = config.recipients?.[config.operator.recipient]?.account ?? config.operator.recipient; + assertString(recipient, `${file}: resolved recipient`); + const validRecipient = config.operator.network.startsWith("tron:") + ? /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(recipient) + : /^0x[0-9a-fA-F]{40}$/.test(recipient); + if (!validRecipient) throw new Error(`${file}: operator.recipient must be a valid address or a resolvable recipient alias`); + + for (const endpoint of config.endpoints ?? []) { + if (!endpoint.metering) continue; + const prices = [endpoint.metering.dimensions?.[0]?.tiers?.[0]?.price_usd, + ...(endpoint.metering.variants ?? []).map(variant => variant.dimensions?.[0]?.tiers?.[0]?.price_usd)] + .filter((price): price is number => typeof price === "number" && price > 0); + for (const price of prices) if (!paymentRequirements(config, price).length) throw new Error(`${file}: paid endpoint cannot generate payment requirements`); } - config.operator.scheme = "exact"; - config.operator.protocol = "exact"; - config.operator.asset_transfer_method = "permit2"; - config.operator.assetTransferMethod = "permit2"; } export function loadProviders(providerPath: string): Map { @@ -206,6 +264,7 @@ export function endpointFor(provider: ProviderConfig, method: string, routePath: } function pathMatches(template: string, routePath: string): boolean { + if (!routePath.startsWith("/") || routePath.startsWith("//") || routePath.includes("\\") || routePath.includes("\0") || /%(?:2f|5c)/i.test(routePath)) return false; const templateParts = template.split("/").filter(Boolean); const routeParts = routePath.split("/").filter(Boolean); if (templateParts.length !== routeParts.length) return false; @@ -230,19 +289,22 @@ export function paymentRequirements(provider: ProviderConfig, price: number): Pa const network = normalizeNetwork(provider.operator.network); const symbols = provider.operator.currencies?.usd ?? ["USDT"]; const payTo = provider.recipients?.[provider.operator.recipient]?.account ?? provider.operator.recipient; - return symbols.map(symbol => { + const schemes: Array = provider.operator.schemes?.length + ? provider.operator.schemes.map(scheme => scheme === "exact_gasfree" ? "exact_gasfree" : "exact") + : [provider.operator.scheme === "exact_gasfree" ? "exact_gasfree" : "exact"]; + return schemes.flatMap(scheme => symbols.map(symbol => { const token = getToken(network, symbol); const transferMethod = provider.operator.assetTransferMethod || provider.operator.asset_transfer_method || token.assetTransferMethod; const amount = toSmallestUnit(price, token.decimals); if (amount === "0") throw new Error(`positive price produced zero amount for ${symbol} on ${network}`); return { - scheme: "exact", + scheme, network, amount, asset: token.address, payTo, maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300, - extra: transferMethod === "permit2" ? { assetTransferMethod: "permit2" } : {}, + extra: scheme === "exact" && transferMethod === "permit2" ? { assetTransferMethod: "permit2" } : {}, }; - }); + })); } diff --git a/src/server.ts b/src/server.ts index e195fd6..1854f30 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,17 @@ import http, { IncomingMessage, ServerResponse } from "node:http"; +import { timingSafeEqual } from "node:crypto"; import { URL } from "node:url"; import type { ProviderEntry } from "./config.js"; import { endpointFor, paymentRequirements, priceUsd } from "./config.js"; import { decodeSignature, encodeRequired, encodeResponse, headers, matchRequirement, type PaymentRequirement } from "./x402.js"; class HttpError extends Error { - constructor(public status: number, public publicMessage: string, message = publicMessage) { + constructor( + public status: number, + public publicMessage: string, + message = publicMessage, + public responseHeaders: Record = {}, + ) { super(message); } } @@ -16,9 +22,21 @@ class RequestTooLargeError extends HttpError { } } -const MAX_BODY_BYTES = Number(process.env.X402_GATEWAY_MAX_BODY_BYTES ?? 1_000_000); -const FACILITATOR_TIMEOUT_MS = Number(process.env.X402_GATEWAY_FACILITATOR_TIMEOUT_MS ?? 10_000); -const UPSTREAM_TIMEOUT_MS = Number(process.env.X402_GATEWAY_UPSTREAM_TIMEOUT_MS ?? 30_000); +function positiveIntegerEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^\d+$/.test(raw)) throw new Error(`${name} must be a positive integer`); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw new Error(`${name} must be an integer between 1 and 2147483647`); + return value; +} + +const MAX_BODY_BYTES = positiveIntegerEnv("X402_GATEWAY_MAX_BODY_BYTES", 1_000_000); +const FACILITATOR_TIMEOUT_MS = positiveIntegerEnv("X402_GATEWAY_FACILITATOR_TIMEOUT_MS", 10_000); +const UPSTREAM_TIMEOUT_MS = positiveIntegerEnv("X402_GATEWAY_UPSTREAM_TIMEOUT_MS", 30_000); +const MAX_RESPONSE_BYTES = positiveIntegerEnv("X402_GATEWAY_MAX_RESPONSE_BYTES", 10_000_000); +const MAX_CONCURRENT_REQUESTS = positiveIntegerEnv("X402_GATEWAY_MAX_CONCURRENT_REQUESTS", 100); +const RATE_LIMIT_PER_MINUTE = positiveIntegerEnv("X402_GATEWAY_RATE_LIMIT_PER_MINUTE", 300); const STRIP_REQUEST_HEADERS = new Set([ "host", "connection", @@ -36,6 +54,8 @@ const STRIP_REQUEST_HEADERS = new Set([ "payment-signature", "payment-required", "x-payment-required", + "payment-response", + "x-payment-response", "accept-encoding", ]); const STRIP_RESPONSE_HEADERS = new Set([ @@ -46,17 +66,34 @@ const STRIP_RESPONSE_HEADERS = new Set([ "authorization", "proxy-authorization", "set-cookie", + "payment-required", + "x-payment-required", + "payment-signature", + "x-payment", + "payment-response", + "x-payment-response", ]); -const metrics = { - requests: 0, - paidRequests: 0, - verifyFailures: 0, - settleFailures: 0, - feeQuoteFailures: 0, - upstreamFailures: 0, +type GatewayMetrics = { + requests: number; + paidRequests: number; + verifyFailures: number; + settleFailures: number; + upstreamFailures: number; + rejectedRequests: number; }; +function createMetrics(): GatewayMetrics { + return { + requests: 0, + paidRequests: 0, + verifyFailures: 0, + settleFailures: 0, + upstreamFailures: 0, + rejectedRequests: 0, + }; +} + async function readBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let total = 0; @@ -70,7 +107,7 @@ async function readBody(request: IncomingMessage): Promise { } function json(response: ServerResponse, status: number, body: unknown, extraHeaders: Record = {}): void { - response.writeHead(status, { "content-type": "application/json", ...extraHeaders }); + response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff", ...extraHeaders }); response.end(JSON.stringify(body)); } @@ -78,77 +115,131 @@ async function fetchWithTimeout(url: URL, init: RequestInit, timeoutMs: number, const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetch(url, { ...init, signal: controller.signal }); + const response = await fetch(url, { ...init, redirect: "manual", signal: controller.signal }); + if (response.status >= 300 && response.status < 400) throw new HttpError(502, `${label} redirect refused`); + return response; } catch (error) { - if ((error as any)?.name === "AbortError") throw new HttpError(504, `${label} request timed out`); + if (error instanceof Error && error.name === "AbortError") throw new HttpError(504, `${label} request timed out`); throw error; } finally { clearTimeout(timer); } } +async function readResponseBytes(response: Response, limit = MAX_RESPONSE_BYTES): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > limit) throw new HttpError(502, "upstream response too large"); + if (!response.body) return Buffer.alloc(0); + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of response.body) { + const buffer = Buffer.from(chunk); + total += buffer.length; + if (total > limit) throw new HttpError(502, "upstream response too large"); + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + async function facilitatorPost(entry: ProviderEntry, path: string, body: unknown): Promise { const headers: Record = { "content-type": "application/json" }; if (entry.facilitatorApiKey) { - headers.authorization = `Bearer ${entry.facilitatorApiKey}`; + headers["x-api-key"] = entry.facilitatorApiKey; } const response = await fetchWithTimeout( - new URL(path, entry.facilitatorUrl), + new URL(path.replace(/^\/+/, ""), `${entry.facilitatorUrl.replace(/\/+$/, "")}/`), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator", ); - const text = await response.text(); + const text = (await readResponseBytes(response, Math.min(MAX_RESPONSE_BYTES, 1_000_000))).toString("utf8"); let data: any = {}; try { data = text ? JSON.parse(text) : {}; } catch { + logFacilitatorFailure(entry, path, response, body, { code: "invalid_json" }); throw new HttpError(502, "facilitator returned invalid response"); } - if (!response.ok) throw new HttpError(502, "facilitator request failed", `facilitator ${path} failed: ${response.status} ${text}`); + if (!response.ok) { + logFacilitatorFailure(entry, path, response, body, data); + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + throw new HttpError( + 429, + "facilitator rate limited", + `facilitator ${path} rate limited`, + retryAfter ? { "retry-after": retryAfter } : {}, + ); + } + throw new HttpError(502, "facilitator request failed", `facilitator ${path} failed: ${response.status}`); + } return data; } -async function attachFeeQuotes(entry: ProviderEntry, requirements: PaymentRequirement[], context?: unknown): Promise { +function configuredPublicBaseUrl(): string | undefined { + const value = process.env.X402_GATEWAY_PUBLIC_BASE_URL?.trim(); + if (!value) return undefined; try { - const quotes = await facilitatorPost(entry, "/fee_quote", { - paymentRequirements: requirements, - context, - }); - const list = Array.isArray(quotes) ? quotes : quotes.quotes ?? quotes.fees ?? []; - return requirements.map(requirement => { - const quote = list.find((item: any) => - item.scheme === requirement.scheme && - item.network === requirement.network && - String(item.asset).toLowerCase() === requirement.asset.toLowerCase(), - ); - return quote?.fee ? { ...requirement, extra: { ...requirement.extra, fee: quote.fee } } : requirement; - }); - } catch (error) { - metrics.feeQuoteFailures += 1; - console.warn("fee quote failed", error); - return requirements; + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol"); + return `${value.replace(/\/+$/, "")}/`; + } catch { + throw new Error("X402_GATEWAY_PUBLIC_BASE_URL must be a valid http(s) URL"); } } +function resourceUrl(url: URL, publicBaseUrl?: string): string { + const path = `${url.pathname}${url.search}`; + if (!publicBaseUrl) return path; + return new URL(path, publicBaseUrl).toString(); +} + +function logFacilitatorFailure( + entry: ProviderEntry, + path: string, + response: Response, + body: unknown, + data: any, +): void { + const requirement = (body as any)?.paymentRequirements; + const nestedError = data?.error && typeof data.error === "object" ? data.error : undefined; + const message = nestedError?.message ?? data?.message ?? data?.detail ?? + (typeof data?.error === "string" ? data.error : undefined); + console.error(JSON.stringify({ + event: "facilitator_request_failed", + provider: entry.config.name, + endpoint: path, + status: response.status, + scheme: requirement?.scheme, + network: requirement?.network, + errorCode: nestedError?.code ?? data?.code, + errorMessage: typeof message === "string" ? message.slice(0, 200) : undefined, + retryAfter: response.headers.get("retry-after") ?? undefined, + cfRay: response.headers.get("cf-ray") ?? undefined, + })); +} + function isVerifySuccess(verify: any): boolean { return verify?.valid === true || verify?.isValid === true; } function isSettleSuccess(settle: any): boolean { - return ( - settle?.success === true || - settle?.settled === true || - (typeof settle?.transaction === "string" && settle.transaction.length > 0) || - (typeof settle?.txHash === "string" && settle.txHash.length > 0) - ); + return settle?.success === true && typeof settle?.transaction === "string" && settle.transaction.length > 0 && typeof settle?.network === "string" && settle.network.length > 0; } function isAdminAllowed(request: IncomingMessage): boolean { const token = process.env.X402_GATEWAY_ADMIN_TOKEN; if (!token) return process.env.X402_GATEWAY_ADMIN_ALLOW_PUBLIC === "true"; const auth = request.headers.authorization ?? ""; - return auth === `Bearer ${token}`; + const expected = Buffer.from(`Bearer ${token}`); + const actual = Buffer.from(auth); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +type RateEntry = { count: number; resetAt: number }; + +function clientAddress(request: IncomingMessage): string { + return request.socket.remoteAddress ?? "unknown"; } function requestParams(url: URL, body: Buffer, request: IncomingMessage): Record { @@ -174,10 +265,11 @@ function requestParams(url: URL, body: Buffer, request: IncomingMessage): Record function upstreamHeaders(request: IncomingMessage, entry: ProviderEntry): Headers { const headersOut = new Headers(); + const connectionHeaders = new Set(String(request.headers.connection ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean)); for (const [key, value] of Object.entries(request.headers)) { if (!value) continue; const lower = key.toLowerCase(); - if (STRIP_REQUEST_HEADERS.has(lower)) continue; + if (STRIP_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower)) continue; headersOut.set(key, Array.isArray(value) ? value.join(",") : value); } const auth = entry.config.routing?.auth; @@ -192,7 +284,12 @@ function upstreamHeaders(request: IncomingMessage, entry: ProviderEntry): Header function upstreamUrl(entry: ProviderEntry, request: IncomingMessage, routePath: string): URL { const sourceUrl = new URL(request.url ?? "/", "http://local"); - const upstream = new URL(routePath + (sourceUrl.search || ""), entry.config.forward_url); + if (!routePath.startsWith("/") || routePath.startsWith("//") || routePath.includes("\\") || routePath.includes("\0") || /%(?:2f|5c)/i.test(routePath)) throw new HttpError(400, "invalid provider path"); + const base = new URL(entry.config.forward_url); + const upstream = new URL(base); + upstream.pathname = routePath; + upstream.search = sourceUrl.search; + if (upstream.origin !== base.origin) throw new HttpError(400, "invalid provider path"); const auth = entry.config.routing?.auth; const value = auth?.value ?? (auth?.value_from_env ? process.env[auth.value_from_env] : undefined); if (auth?.method === "query_param" && value) { @@ -201,7 +298,7 @@ function upstreamUrl(entry: ProviderEntry, request: IncomingMessage, routePath: return upstream; } -async function forward(entry: ProviderEntry, request: IncomingMessage, response: ServerResponse, routePath: string, body: Buffer, paymentResponse?: unknown): Promise { +async function forward(metrics: GatewayMetrics, entry: ProviderEntry, request: IncomingMessage, response: ServerResponse, routePath: string, body: Buffer, paymentResponse?: unknown): Promise { const upstream = upstreamUrl(entry, request, routePath); let upstreamResponse: Response; try { @@ -222,12 +319,24 @@ async function forward(entry: ProviderEntry, request: IncomingMessage, response: } }); if (paymentResponse) responseHeaders[headers.response] = encodeResponse(paymentResponse); + let responseBody: Buffer; + try { + responseBody = await readResponseBytes(upstreamResponse); + } catch (error) { + metrics.upstreamFailures += 1; + throw error; + } response.writeHead(upstreamResponse.status, responseHeaders); - response.end(Buffer.from(await upstreamResponse.arrayBuffer())); + response.end(responseBody); } export function createGatewayServer(providers: Map): http.Server { - return http.createServer(async (request, response) => { + const publicBaseUrl = configuredPublicBaseUrl(); + const metrics = createMetrics(); + const rateLimits = new Map(); + let activeRequests = 0; + const server = http.createServer(async (request, response) => { + let countedActive = false; try { metrics.requests += 1; const url = new URL(request.url ?? "/", "http://local"); @@ -271,8 +380,8 @@ export function createGatewayServer(providers: Map): http `x402_gateway_paid_requests_total ${metrics.paidRequests}`, `x402_gateway_verify_failures_total ${metrics.verifyFailures}`, `x402_gateway_settle_failures_total ${metrics.settleFailures}`, - `x402_gateway_fee_quote_failures_total ${metrics.feeQuoteFailures}`, `x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`, + `x402_gateway_rejected_requests_total ${metrics.rejectedRequests}`, "", ].join("\n")); return; @@ -293,19 +402,40 @@ export function createGatewayServer(providers: Map): http json(response, 404, { error: "endpoint not found" }); return; } + const now = Date.now(); + const address = clientAddress(request); + let rate = rateLimits.get(address); + if (!rate || rate.resetAt <= now) { + rate = { count: 0, resetAt: now + 60_000 }; + rateLimits.set(address, rate); + } + rate.count += 1; + if (rate.count > RATE_LIMIT_PER_MINUTE) { + metrics.rejectedRequests += 1; + const retryAfter = Math.max(1, Math.ceil((rate.resetAt - now) / 1000)); + throw new HttpError(429, "gateway rate limited", undefined, { "retry-after": String(retryAfter) }); + } + if (activeRequests >= MAX_CONCURRENT_REQUESTS) { + metrics.rejectedRequests += 1; + throw new HttpError(503, "gateway is busy", undefined, { "retry-after": "1" }); + } + activeRequests += 1; + countedActive = true; const body = await readBody(request); - const requirements = paymentRequirements(entry.config, priceUsd(endpoint, requestParams(url, body, request))); + const price = priceUsd(endpoint, requestParams(url, body, request)); + const requirements = paymentRequirements(entry.config, price); + if (price > 0 && !requirements.length) throw new HttpError(500, "paid endpoint has no payment requirements"); if (!requirements.length) { - await forward(entry, request, response, routePath, body); + await forward(metrics, entry, request, response, routePath, body); return; } const paymentHeader = request.headers[headers.signature.toLowerCase()]; if (!paymentHeader || Array.isArray(paymentHeader)) { - const accepts = await attachFeeQuotes(entry, requirements); + const accepts = requirements; const challenge = { x402Version: 2, error: "Payment required", - resource: { url: url.pathname }, + resource: { url: resourceUrl(url, publicBaseUrl) }, accepts, }; json(response, 402, challenge, { [headers.required]: encodeRequired(challenge) }); @@ -348,14 +478,33 @@ export function createGatewayServer(providers: Map): http return; } metrics.paidRequests += 1; - await forward(entry, request, response, routePath, body, settle); + try { + await forward(metrics, entry, request, response, routePath, body, settle); + } catch (error) { + const status = error instanceof HttpError ? error.status : 502; + const extraHeaders = error instanceof HttpError ? error.responseHeaders : {}; + json(response, status, { + error: "upstream failed after payment settlement", + settled: true, + }, { + ...extraHeaders, + [headers.response]: encodeResponse(settle), + }); + } } catch (error) { if (error instanceof HttpError) { - json(response, error.status, { error: error.publicMessage }); + json(response, error.status, { error: error.publicMessage }, error.responseHeaders); return; } console.error(error); json(response, 500, { error: "internal server error" }); + } finally { + if (countedActive) activeRequests -= 1; } }); + server.requestTimeout = UPSTREAM_TIMEOUT_MS + FACILITATOR_TIMEOUT_MS * 2 + 5_000; + server.headersTimeout = Math.min(server.requestTimeout, 60_000); + server.keepAliveTimeout = 5_000; + server.maxConnections = MAX_CONCURRENT_REQUESTS * 2; + return server; } diff --git a/src/tokens.ts b/src/tokens.ts index fb7b04e..0d8e330 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -7,11 +7,11 @@ export type TokenInfo = { }; export const TOKENS: Record> = { - "tron:mainnet": { + "tron:0x2b6653dc": { USDT: { address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" }, USDD: { address: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", decimals: 18, name: "Decentralized USD", symbol: "USDD", assetTransferMethod: "permit2" }, }, - "tron:nile": { + "tron:0xcd8690dc": { USDT: { address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", decimals: 6, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" }, USDD: { address: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", decimals: 18, name: "Decentralized USD", symbol: "USDD", assetTransferMethod: "permit2" }, }, @@ -25,15 +25,25 @@ export const TOKENS: Record> = { }; export function normalizeNetwork(network: string): string { - return ( - { - "tron-mainnet": "tron:mainnet", - "tron-shasta": "tron:shasta", - "tron-nile": "tron:nile", - "bsc-mainnet": "eip155:56", - "bsc-testnet": "eip155:97", - }[network] ?? network - ); + const legacyTronIds: Record = { + "tron-mainnet": "tron:0x2b6653dc", + "tron:mainnet": "tron:0x2b6653dc", + mainnet: "tron:0x2b6653dc", + "tron-shasta": "tron:0x94a9059e", + "tron:shasta": "tron:0x94a9059e", + shasta: "tron:0x94a9059e", + "tron-nile": "tron:0xcd8690dc", + "tron:nile": "tron:0xcd8690dc", + nile: "tron:0xcd8690dc", + }; + const canonical = legacyTronIds[network]; + if (canonical) { + throw new Error(`legacy TRON network identifier ${network} is not supported; use ${canonical}`); + } + return { + "bsc-mainnet": "eip155:56", + "bsc-testnet": "eip155:97", + }[network] ?? network; } export function getToken(network: string, symbol: string): TokenInfo { diff --git a/src/x402.ts b/src/x402.ts index 93432df..14a81ad 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -11,7 +11,7 @@ export const headers = { }; export type PaymentRequirement = { - scheme: "exact"; + scheme: "exact" | "exact_gasfree"; network: string; amount: string; asset: string; diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index d6ce780..d99785a 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; @@ -10,9 +10,13 @@ const root = path.resolve(import.meta.dirname, ".."); const cli = path.join(root, "dist", "cli.js"); function run(args, options = {}) { + const env = { ...process.env, ...(options.env ?? {}) }; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete env[key]; + } return spawnSync(process.execPath, [cli, ...args], { cwd: options.cwd ?? root, - env: { ...process.env, ...(options.env ?? {}) }, + env, encoding: "utf8", }); } @@ -41,7 +45,7 @@ function providerFixture() { writeFileSync(path.join(providerDir, "provider.yml"), `name: demo-provider forward_url: http://127.0.0.1:65535 operator: - network: tron-nile + network: tron:0xcd8690dc recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i currencies: usd: ["USDT"] @@ -119,6 +123,22 @@ test("check validates providers without starting a server", () => { } }); +test("check rejects a configured but missing facilitator API key variable", () => { + const dir = providerFixture(); + try { + const providerFile = path.join(dir, "demo", "provider.yml"); + const source = readFileSync(providerFile, "utf8"); + writeFileSync(providerFile, source.replace(" protocol: exact", " protocol: exact\n facilitator_api_key_env: TEST_MISSING_FACILITATOR_KEY")); + const result = run(["check", "--providers", dir, "--json"], { + env: { TEST_MISSING_FACILITATOR_KEY: undefined }, + }); + assert.equal(result.status, 1); + assert.match(JSON.parse(result.stdout).error.message, /TEST_MISSING_FACILITATOR_KEY is not set/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("provider loading errors include action and source", () => { const missing = run(["check", "--providers", "/tmp/x402-gateway-missing-provider-dir"]); assert.equal(missing.status, 1); diff --git a/tests/fixtures/container-gasfree/provider.yml b/tests/fixtures/container-gasfree/provider.yml new file mode 100644 index 0000000..7b7578e --- /dev/null +++ b/tests/fixtures/container-gasfree/provider.yml @@ -0,0 +1,20 @@ +name: gasfree-container-test +forward_url: http://127.0.0.1:65534 +operator: + network: tron:0xcd8690dc + currencies: + usd: ["USDT"] + recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i + schemes: [exact, exact_gasfree] + scheme: exact + protocol: exact + asset_transfer_method: permit2 + facilitator_url: http://127.0.0.1:65534 + valid_for_seconds: 300 +endpoints: + - method: GET + path: /v1/ping + metering: + dimensions: + - tiers: + - price_usd: 0.000001 diff --git a/tests/gateway.test.mjs b/tests/gateway.test.mjs index 642ec92..576d4fc 100644 --- a/tests/gateway.test.mjs +++ b/tests/gateway.test.mjs @@ -1,12 +1,14 @@ import assert from "node:assert/strict"; import http from "node:http"; import { afterEach, beforeEach, test } from "node:test"; -import { encodePaymentSignatureHeader } from "@bankofai/x402-core/http"; +import { decodePaymentResponseHeader, encodePaymentSignatureHeader } from "@bankofai/x402-core/http"; import { createGatewayServer } from "../dist/server.js"; -import { toSmallestUnit } from "../dist/tokens.js"; +import { paymentRequirements } from "../dist/config.js"; +import { normalizeNetwork, toSmallestUnit } from "../dist/tokens.js"; let servers = []; let oldAdminToken; +let oldPublicBaseUrl; function listen(server) { return new Promise(resolve => { @@ -30,6 +32,20 @@ async function startUpstream() { return { url: `http://127.0.0.1:${port}`, hits: () => hits }; } +async function startSpoofingUpstream() { + const server = http.createServer((_request, response) => { + response.writeHead(200, { + "content-type": "application/json", + "PAYMENT-REQUIRED": "spoofed-required", + "PAYMENT-RESPONSE": "spoofed-response", + }); + response.end(JSON.stringify({ ok: true })); + }); + const port = await listen(server); + servers.push(server); + return `http://127.0.0.1:${port}`; +} + async function startFacilitator(handlers = {}) { const server = http.createServer((request, response) => { const handler = handlers[request.url]; @@ -41,15 +57,17 @@ async function startFacilitator(handlers = {}) { return `http://127.0.0.1:${port}`; } -async function startGateway({ facilitatorUrl, upstreamUrl }) { +async function startGateway({ facilitatorUrl, upstreamUrl, facilitatorApiKey, network = "eip155:56", recipient = "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", scheme = "exact" }) { const entry = { facilitatorUrl, + facilitatorApiKey, config: { name: "paid-provider", forward_url: upstreamUrl, operator: { - network: "eip155:56", - recipient: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + network, + recipient, + scheme, valid_for_seconds: 300, }, endpoints: [ @@ -71,12 +89,15 @@ async function startGateway({ facilitatorUrl, upstreamUrl }) { beforeEach(() => { oldAdminToken = process.env.X402_GATEWAY_ADMIN_TOKEN; + oldPublicBaseUrl = process.env.X402_GATEWAY_PUBLIC_BASE_URL; process.env.X402_GATEWAY_ADMIN_TOKEN = "test-admin"; }); afterEach(async () => { if (oldAdminToken === undefined) delete process.env.X402_GATEWAY_ADMIN_TOKEN; else process.env.X402_GATEWAY_ADMIN_TOKEN = oldAdminToken; + if (oldPublicBaseUrl === undefined) delete process.env.X402_GATEWAY_PUBLIC_BASE_URL; + else process.env.X402_GATEWAY_PUBLIC_BASE_URL = oldPublicBaseUrl; await Promise.all(servers.map(server => new Promise(resolve => server.close(resolve)))); servers = []; }); @@ -87,6 +108,49 @@ test("amount conversion handles tiny decimal prices without producing zero", () assert.equal(toSmallestUnit("0.000000000000000001", 18), "1"); }); +test("legacy TRON aliases are rejected in favor of canonical CAIP-2 IDs", () => { + assert.throws(() => normalizeNetwork("tron:nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron-nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron:mainnet"), /use tron:0x2b6653dc/); + assert.throws(() => normalizeNetwork("tron:shasta"), /use tron:0x94a9059e/); +}); + +test("TRON GasFree providers emit exact_gasfree requirements without Permit2 metadata", () => { + const requirements = paymentRequirements({ + name: "gasfree-provider", + forward_url: "https://example.com", + operator: { + network: "tron:0xcd8690dc", + recipient: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + scheme: "exact_gasfree", + currencies: { usd: ["USDT"] }, + }, + endpoints: [], + }, 0.000001); + + assert.equal(requirements[0].scheme, "exact_gasfree"); + assert.equal(requirements[0].network, "tron:0xcd8690dc"); + assert.deepEqual(requirements[0].extra, {}); +}); + +test("TRON providers can advertise Exact Permit2 and GasFree together", () => { + const requirements = paymentRequirements({ + name: "dual-tron-provider", + forward_url: "https://example.com", + operator: { + network: "tron:0x2b6653dc", + recipient: "TLXPgJVJFgL97gc49j8w8kC22mDTpH9EGa", + schemes: ["exact", "exact_gasfree"], + currencies: { usd: ["USDT"] }, + }, + endpoints: [], + }, 0.000001); + + assert.deepEqual(requirements.map(requirement => requirement.scheme), ["exact", "exact_gasfree"]); + assert.deepEqual(requirements[0].extra, { assetTransferMethod: "permit2" }); + assert.deepEqual(requirements[1].extra, {}); +}); + test("admin endpoints and metrics require the admin token", async () => { const upstream = await startUpstream(); const facilitatorUrl = await startFacilitator(); @@ -99,6 +163,23 @@ test("admin endpoints and metrics require the admin token", async () => { })).status, 200); }); +test("metrics are isolated between gateway server instances", async () => { + const first = createGatewayServer(new Map()); + const firstPort = await listen(first); + servers.push(first); + await fetch(`http://127.0.0.1:${firstPort}/__402/health`); + await fetch(`http://127.0.0.1:${firstPort}/__402/health`); + + const second = createGatewayServer(new Map()); + const secondPort = await listen(second); + servers.push(second); + const response = await fetch(`http://127.0.0.1:${secondPort}/metrics`, { + headers: { authorization: "Bearer test-admin" }, + }); + assert.equal(response.status, 200); + assert.match(await response.text(), /x402_gateway_requests_total 1(?:\n|$)/); +}); + test("unpaid requests return a payment challenge", async () => { const upstream = await startUpstream(); const facilitatorUrl = await startFacilitator(); @@ -113,6 +194,54 @@ test("unpaid requests return a payment challenge", async () => { assert.equal(upstream.hits(), 0); }); +test("public base URL produces an absolute challenge resource URL", async () => { + process.env.X402_GATEWAY_PUBLIC_BASE_URL = "https://tm-x402-gateway.bankofai.io/"; + const upstream = await startUpstream(); + const facilitatorUrl = await startFacilitator(); + const gatewayUrl = await startGateway({ facilitatorUrl, upstreamUrl: upstream.url }); + + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt?source=qa`); + const body = await response.json(); + + assert.equal(response.status, 402); + assert.equal( + body.resource.url, + "https://tm-x402-gateway.bankofai.io/providers/paid-provider/price/usdt?source=qa", + ); +}); + +test("invalid public base URL is rejected at startup", () => { + process.env.X402_GATEWAY_PUBLIC_BASE_URL = "not-a-url"; + assert.throws(() => createGatewayServer(new Map()), /must be a valid http\(s\) URL/); +}); + +test("GasFree challenges omit legacy facilitator fee quotes", async () => { + const upstream = await startUpstream(); + let feeQuoteRequests = 0; + const facilitatorUrl = await startFacilitator({ + "/fee_quote": (_request, response) => { + feeQuoteRequests += 1; + json(response, 500, { error: "legacy endpoint must not be called" }); + }, + }); + const gatewayUrl = await startGateway({ + facilitatorUrl, + upstreamUrl: upstream.url, + network: "tron:0xcd8690dc", + recipient: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + scheme: "exact_gasfree", + }); + + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt`); + const body = await response.json(); + + assert.equal(response.status, 402); + assert.equal(body.accepts[0].scheme, "exact_gasfree"); + assert.equal(body.accepts[0].extra.fee, undefined); + assert.equal(body.accepts[0].extra.assetTransferMethod, undefined); + assert.equal(feeQuoteRequests, 0); +}); + test("invalid payment signatures are rejected as client errors", async () => { const upstream = await startUpstream(); const facilitatorUrl = await startFacilitator(); @@ -150,3 +279,238 @@ test("facilitator verify must explicitly succeed before forwarding", async () => assert.equal(response.status, 400); assert.equal(upstream.hits(), 0); }); + +test("explicit settlement failure is not overridden by transaction metadata", async () => { + const upstream = await startUpstream(); + const facilitatorUrl = await startFacilitator({ + "/verify": (_request, response) => json(response, 200, { valid: true }), + "/settle": (_request, response) => json(response, 200, { + success: false, + transaction: "failed-transaction", + }), + }); + const gatewayUrl = await startGateway({ facilitatorUrl, upstreamUrl: upstream.url }); + const signature = encodePaymentSignatureHeader({ + accepted: { + scheme: "exact", + network: "eip155:56", + amount: "1000000000000", + asset: "0x55d398326f99059fF775485246999027B3197955", + payTo: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + }, + signature: "test", + }); + + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt`, { + headers: { "PAYMENT-SIGNATURE": signature }, + }); + + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "settlement failed" }); + assert.equal(upstream.hits(), 0); +}); + +test("facilitator failures log status and routing metadata without payment payloads", async () => { + const upstream = await startUpstream(); + const facilitatorUrl = await startFacilitator({ + "/verify": (_request, response) => { + response.writeHead(429, { "content-type": "application/json", "retry-after": "36" }); + response.end(JSON.stringify({ + error: { code: "RATE_LIMITED", message: "try again later" }, + })); + }, + }); + const gatewayUrl = await startGateway({ facilitatorUrl, upstreamUrl: upstream.url }); + const signature = encodePaymentSignatureHeader({ + accepted: { + scheme: "exact", + network: "eip155:56", + amount: "1000000000000", + asset: "0x55d398326f99059fF775485246999027B3197955", + payTo: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + }, + signature: "sensitive-test-signature", + }); + const messages = []; + const originalError = console.error; + console.error = message => messages.push(String(message)); + try { + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt`, { + headers: { "PAYMENT-SIGNATURE": signature }, + }); + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "36"); + assert.deepEqual(await response.json(), { error: "facilitator rate limited" }); + } finally { + console.error = originalError; + } + + assert.equal(messages.length, 1); + const log = JSON.parse(messages[0]); + assert.deepEqual({ + event: log.event, + provider: log.provider, + endpoint: log.endpoint, + status: log.status, + scheme: log.scheme, + network: log.network, + errorCode: log.errorCode, + }, { + event: "facilitator_request_failed", + provider: "paid-provider", + endpoint: "/verify", + status: 429, + scheme: "exact", + network: "eip155:56", + errorCode: "RATE_LIMITED", + }); + assert.equal(messages[0].includes("sensitive-test-signature"), false); + assert.equal(upstream.hits(), 0); +}); + +test("facilitator API keys use the X-API-KEY header", async () => { + const upstream = await startUpstream(); + const receivedKeys = []; + const facilitatorUrl = await startFacilitator({ + "/verify": (request, response) => { + receivedKeys.push(request.headers["x-api-key"]); + assert.equal(request.headers.authorization, undefined); + json(response, 200, { valid: true }); + }, + "/settle": (request, response) => { + receivedKeys.push(request.headers["x-api-key"]); + assert.equal(request.headers.authorization, undefined); + json(response, 200, { success: true, transaction: "test-transaction", network: "eip155:56" }); + }, + }); + const gatewayUrl = await startGateway({ + facilitatorUrl, + upstreamUrl: upstream.url, + facilitatorApiKey: "secret-facilitator-key", + }); + const signature = encodePaymentSignatureHeader({ + accepted: { + scheme: "exact", + network: "eip155:56", + amount: "1000000000000", + asset: "0x55d398326f99059fF775485246999027B3197955", + payTo: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + }, + signature: "test", + }); + + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt`, { + headers: { "PAYMENT-SIGNATURE": signature }, + }); + assert.equal(response.status, 200); + assert.deepEqual(receivedKeys, ["secret-facilitator-key", "secret-facilitator-key"]); +}); + +test("settled payments retain PAYMENT-RESPONSE when upstream connection fails", async () => { + const unavailable = http.createServer(); + const unavailablePort = await listen(unavailable); + await new Promise(resolve => unavailable.close(resolve)); + const facilitatorUrl = await startFacilitator({ + "/verify": (_request, response) => json(response, 200, { valid: true }), + "/settle": (_request, response) => json(response, 200, { success: true, transaction: "settled-transaction", network: "eip155:56" }), + }); + const gatewayUrl = await startGateway({ + facilitatorUrl, + upstreamUrl: `http://127.0.0.1:${unavailablePort}`, + }); + const signature = encodePaymentSignatureHeader({ + accepted: { + scheme: "exact", + network: "eip155:56", + amount: "1000000000000", + asset: "0x55d398326f99059fF775485246999027B3197955", + payTo: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + }, + signature: "test", + }); + + const response = await fetch(`${gatewayUrl}/providers/paid-provider/price/usdt`, { + headers: { "PAYMENT-SIGNATURE": signature }, + }); + assert.equal(response.status, 502); + assert.equal((await response.json()).settled, true); + assert.equal(decodePaymentResponseHeader(response.headers.get("PAYMENT-RESPONSE")).transaction, "settled-transaction"); +}); + +test("upstream services cannot spoof x402 response headers", async () => { + const upstreamUrl = await startSpoofingUpstream(); + const entry = { + facilitatorUrl: "http://127.0.0.1:1", + config: { + name: "free-provider", + forward_url: upstreamUrl, + operator: { + network: "eip155:56", + recipient: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86", + }, + endpoints: [{ method: "GET", path: "/free" }], + }, + }; + const server = createGatewayServer(new Map([[entry.config.name, entry]])); + const port = await listen(server); + servers.push(server); + const response = await fetch(`http://127.0.0.1:${port}/providers/free-provider/free`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("PAYMENT-REQUIRED"), null); + assert.equal(response.headers.get("PAYMENT-RESPONSE"), null); +}); + +test("upstream redirects are not followed", async () => { + let redirectedHits = 0; + const redirected = http.createServer((_request, response) => { + redirectedHits += 1; + json(response, 200, { leaked: true }); + }); + const redirectedPort = await listen(redirected); + servers.push(redirected); + const redirector = http.createServer((_request, response) => { + response.writeHead(302, { location: `http://127.0.0.1:${redirectedPort}/secret` }); + response.end(); + }); + const redirectorPort = await listen(redirector); + servers.push(redirector); + const entry = { + facilitatorUrl: "http://127.0.0.1:1", + config: { + name: "redirect-provider", + forward_url: `http://127.0.0.1:${redirectorPort}`, + operator: { network: "eip155:56", recipient: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86" }, + endpoints: [{ method: "GET", path: "/free" }], + }, + }; + const server = createGatewayServer(new Map([[entry.config.name, entry]])); + const port = await listen(server); + servers.push(server); + const response = await fetch(`http://127.0.0.1:${port}/providers/redirect-provider/free`); + assert.equal(response.status, 502); + assert.equal(redirectedHits, 0); +}); + +test("provider routes are rate limited before reaching upstream", async () => { + const upstream = await startUpstream(); + const entry = { + facilitatorUrl: "http://127.0.0.1:1", + config: { + name: "rate-provider", + forward_url: upstream.url, + operator: { network: "eip155:56", recipient: "0x7bac3352Bc5F342DcaFA573749aA4502CB12dA86" }, + endpoints: [{ method: "GET", path: "/free" }], + }, + }; + const server = createGatewayServer(new Map([[entry.config.name, entry]])); + const port = await listen(server); + servers.push(server); + const url = `http://127.0.0.1:${port}/providers/rate-provider/free`; + for (let index = 0; index < 300; index += 1) { + assert.equal((await fetch(url)).status, 200); + } + const rejected = await fetch(url); + assert.equal(rejected.status, 429); + assert.ok(Number(rejected.headers.get("retry-after")) >= 1); + assert.equal(upstream.hits(), 300); +});