diff --git a/.env.facilitator.example b/.env.facilitator.example new file mode 100644 index 0000000..3e353fe --- /dev/null +++ b/.env.facilitator.example @@ -0,0 +1,16 @@ +# Select the baked-in environment config: dev or prod. +FACILITATOR_SERVICE_ENV=dev + +# Optional explicit config-path override; takes precedence over FACILITATOR_SERVICE_ENV. +# FACILITATOR_CONFIG_PATH=/app/config/facilitator.config.dev.yaml + +# Required when resolving the onepassword references in the selected YAML. +OP_SERVICE_ACCOUNT_TOKEN= + +# Optional overrides; when set, these take precedence over 1Password. +# AGENT_WALLET_PASSWORD= +# TRON_GRID_API_KEY= +# GASFREE_API_KEY_NILE= +# GASFREE_API_SECRET_NILE= +# GASFREE_API_KEY_MAINNET= +# GASFREE_API_SECRET_MAINNET= diff --git a/.gitignore b/.gitignore index 9dada93..6b03a15 100644 --- a/.gitignore +++ b/.gitignore @@ -87,7 +87,7 @@ venv.bak/ *.log logs -# Config (contains sensitive info) +# Config (contains environment-specific endpoints and secret references) facilitator.config.yaml # Private @@ -99,4 +99,4 @@ dist/ *.tsbuildinfo npm-debug.log* pnpm-debug.log* -yarn-debug.log* \ No newline at end of file +yarn-debug.log* diff --git a/CLAUDE.md b/CLAUDE.md index 292d800..d55fa03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,14 +15,15 @@ npm run dev # tsx watch (reload on change) npm run build # tsc -> dist/ npm start # run compiled dist/index.js npm run typecheck # tsc --noEmit — the primary static check (see lint note below) -npm test # vitest run (37 tests, no DB required) +npm test # vitest run (no DB required) npm test -- test/settlement.test.ts # single file npm test -- -t "applies the authenticated tier" # single test by name ``` -Before running, copy the config template: `cp config/facilitator.config.example.yaml config/facilitator.config.yaml`. +Before running, select a config explicitly: `FACILITATOR_SERVICE_ENV=dev npm run dev`, +`FACILITATOR_SERVICE_ENV=prod npm start`, or set `FACILITATOR_CONFIG_PATH`. -**Lint:** `npm run lint` references eslint, but eslint is **not installed and not configured** — the script does not work. Use `npm run typecheck` as the static check. CI (`.github/workflows/ci.yml`, `lint-and-test` job) runs `npm ci` → `typecheck` → `test`. +**Lint:** run `npm run lint` alongside `npm run typecheck`. CI (`.github/workflows/ci.yml`, `lint-and-test` job) runs `npm ci` → production dependency audit → lint → typecheck → test. ## Architecture @@ -41,13 +42,13 @@ Key seams: - **Auth (`src/auth.ts`)** is **advisory, not a hard gate** — anonymous requests are allowed at the anonymous rate. A valid `X-API-KEY` selects the authenticated rate tier and scopes payment lookups to that seller. Keys are held in an in-memory cache refreshed periodically from the DB, checked in constant time. -- **Config & secrets (`src/config.ts`)** — YAML (`config/facilitator.config.yaml`, override path via `FACILITATOR_CONFIG_PATH`). Secrets resolve **env first, then 1Password**: any `onepassword.*` value is a `vault/item/field` ref resolved when `OP_SERVICE_ACCOUNT_TOKEN` / `onepassword.token` is set. A network listed under `facilitator.networks` is enabled. +- **Config & secrets (`src/config.ts`)** — `FACILITATOR_SERVICE_ENV=dev|prod` selects the matching baked-in YAML; `FACILITATOR_CONFIG_PATH` is an explicit override. Secrets resolve **env first, then 1Password**: any `onepassword.*` value is a `vault/item/field` ref resolved when `OP_SERVICE_ACCOUNT_TOKEN` / `onepassword.token` is set. A network listed under `facilitator.networks` is enabled. - **Database (`src/db/`, drizzle + `pg`)** — v2 owns a new `settlements` table (created on startup), keyed on the on-chain **authorization identity** `(network, scheme, asset, payer, nonce)`, with a partial-unique index enforcing one successful settlement per authorization. The shared `sellers` / `api_keys_plus` tables are reused unchanged. v1's `payment_records` is unused. ## Conventions & gotchas -- Networks use **CAIP-2** ids (`tron:nile`, `tron:mainnet`, `bsc:testnet`, `eip155:*`). `isTron`/`isEvm` in `facilitator.ts` route by prefix. +- Networks use supported canonical **CAIP-2** ids only (for example `tron:0xcd8690dc`, `tron:0x2b6653dc`, `eip155:97`, `eip155:84532`). Friendly aliases are rejected; `facilitator.ts` routes by the registered network family. - The `@bankofai/x402-*` packages (`x402-core`, `x402-evm`, `x402-tron`, `x402-extensions`) come from the npm registry. Pin to the tested version deliberately — bumping is a separate, deliberate upgrade (API drift risk). When the SDK's interface is awkward, surface the gap rather than silently `any`-adapting around it. - Fees were removed from the TRON schemes in SDK `1.0.1`; there is no `base_fee` config and no `/fee/quote` endpoint. - Stale Python artifacts (`src/**/__pycache__`, `tests/__pycache__`) are leftovers from v1 — ignore them; the live tests are TS files under `test/`. diff --git a/Dockerfile b/Dockerfile index 700486b..8dabc28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,11 @@ ENV NODE_ENV=production COPY package.json package-lock.json* ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist +# Bake the non-secret environment configs into the image. They contain only +# 1Password references; OP_SERVICE_ACCOUNT_TOKEN remains a runtime env variable. +# Copy explicitly so a local config/facilitator.config.yaml is never included. +COPY config/facilitator.config.dev.yaml ./config/facilitator.config.dev.yaml +COPY config/facilitator.config.prod.yaml ./config/facilitator.config.prod.yaml # Non-root runtime user matching legacy v1 exactly: `ec2-user` at uid/gid 1000 with # HOME /home/ec2-user. The ops `docker run` bind-mounts the provisioned agent-wallet diff --git a/README.md b/README.md index e8d6c56..ae10631 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,8 @@ A TypeScript/Node service. The earlier Python/FastAPI implementation is kept und ### Install and run ```bash -npm install -cp config/facilitator.config.example.yaml config/facilitator.config.yaml -npm run dev # tsx watch; or: npm run build && npm start +npm ci +FACILITATOR_SERVICE_ENV=dev npm run dev ``` Default listen address: `http://0.0.0.0:8001`. @@ -51,9 +50,10 @@ Default listen address: `http://0.0.0.0:8001`. ## Configuration -YAML config (`config/facilitator.config.yaml`; template: -[`config/facilitator.config.example.yaml`](config/facilitator.config.example.yaml)). -Path override: `FACILITATOR_CONFIG_PATH`. +Choose a YAML configuration source explicitly. Set `FACILITATOR_SERVICE_ENV=dev` or +`FACILITATOR_SERVICE_ENV=prod` to select the matching baked-in environment config, or +set `FACILITATOR_CONFIG_PATH` to an explicit YAML file; the explicit path takes +precedence. The process fails before startup when neither is set. Required: `database.url`, `facilitator.networks` (≥1 network, listed = enabled). @@ -63,6 +63,8 @@ is set). Relevant env vars: | Var | Purpose | |---|---| +| `FACILITATOR_SERVICE_ENV` | `dev` or `prod`; selects the matching baked-in config file | +| `FACILITATOR_CONFIG_PATH` | Explicit config path; overrides `FACILITATOR_SERVICE_ENV` | | `AGENT_WALLET_PASSWORD` | Unlock the agent-wallet provider | | `TRON_GRID_API_KEY` | TronGrid rate limits (shared across TRON networks) | | `GASFREE_API_KEY[_NILE\|_MAINNET]` / `GASFREE_API_SECRET[...]` | GasFree relayer creds (gate `exact_gasfree`) | @@ -109,9 +111,8 @@ npm, declared as `^1.0.1` in `package.json`. docker build -t x402-facilitator . docker run -p 8001:8001 -p 9001:9001 \ - -e OP_SERVICE_ACCOUNT_TOKEN="" \ - -e AGENT_WALLET_PASSWORD="" \ - -v "$PWD/config/facilitator.config.yaml:/app/config/facilitator.config.yaml:ro" \ + -e FACILITATOR_SERVICE_ENV=dev \ + -e OP_SERVICE_ACCOUNT_TOKEN \ -v "$PWD/logs:/app/logs" \ x402-facilitator ``` @@ -122,8 +123,17 @@ password is resolved from `OP_SERVICE_ACCOUNT_TOKEN` (1Password) when set; otherwise pass it directly via `AGENT_WALLET_PASSWORD`. Port `9001` is only needed when `monitoring.port` differs from `server.port`. +Both `config/facilitator.config.dev.yaml` and +`config/facilitator.config.prod.yaml` are baked into the image. Select one at +runtime with `FACILITATOR_SERVICE_ENV=dev` or `FACILITATOR_SERVICE_ENV=prod`; no +config-directory mount is required. `FACILITATOR_CONFIG_PATH` remains available +for an explicit custom path. +`OP_SERVICE_ACCOUNT_TOKEN` must be injected only at container runtime (for +example by the deployment platform's secret environment-variable facility); +it is never stored in the image or either YAML file. + ## Status Feature-complete and unit-tested; **not yet validated against live chains** (real -verify+settle on tron:nile / bsc:testnet and GasFree end-to-end are pending), and +verify+settle on `tron:0xcd8690dc` / `eip155:97` and GasFree end-to-end are pending), and without integration tests yet. diff --git a/config/facilitator.config.dev.yaml b/config/facilitator.config.dev.yaml new file mode 100644 index 0000000..d0fb2f7 --- /dev/null +++ b/config/facilitator.config.dev.yaml @@ -0,0 +1,48 @@ +# Local runtime config for the development environment. +# Start with: +# FACILITATOR_SERVICE_ENV=dev npm run dev + +database: + # node-postgres accepts this legacy SQLAlchemy/asyncpg URL form; credentials + # are injected from onepassword below before connecting. + url: "postgresql+asyncpg://sun-agent-postgresql.chqcqywoo8fb.us-east-1.rds.amazonaws.com:5432/x402_facilitator" + ssl_mode: "require" + max_open_conns: 100 + +onepassword: + database_user: "x402-facilitator_dev/psql/user" + database_password: "x402-facilitator_dev/psql/password" + trongrid_api_key: "x402-facilitator_dev/trongrid/trongrid_api_key" + gasfree_api_key_nile: "x402-facilitator_dev/gasfree/gasfree_api_key_nile" + gasfree_api_secret_nile: "x402-facilitator_dev/gasfree/gasfree_api_secret_nile" + agent_wallet_password: "x402-facilitator_dev/wallet/agent_wallet_password" + +server: + host: "0.0.0.0" + port: 8001 + +logging: + dir: "logs" + filename: "app.log" + level: "INFO" + +rate_limit: + api_key_refresh_interval: 60 + authenticated: "1000/minute" + anonymous: "1/minute" + +monitoring: + port: 9001 + endpoint: "/metrics" + +facilitator: + networks: + # TRON Nile testnet + tron:0xcd8690dc: + schemes: ["exact", "upto", "batch-settlement"] + # BSC Testnet + eip155:97: + schemes: ["exact", "upto", "batch-settlement"] + # Base Sepolia testnet + eip155:84532: + schemes: ["exact", "upto", "batch-settlement"] diff --git a/config/facilitator.config.example.yaml b/config/facilitator.config.example.yaml deleted file mode 100644 index 37b17da..0000000 --- a/config/facilitator.config.example.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# x402 Facilitator v2 config (TypeScript / upstream @x402/* ecosystem) - -server: - host: "0.0.0.0" - port: 8001 - -logging: - level: "info" - # File logging. Both dir + filename set => append logs to a file (fixed name, - # not timestamped) in addition to console. Matches legacy defaults; comment out - # to disable file logging. - dir: "logs" - filename: "x402-facilitator.log" - -database: - url: "" - # ssl_mode: "disable" # disable | require | verify-ca | verify-full - # max_open_conns: 25 - # max_idle_conns: 15 - # max_life_time: 600 - -# 1Password: each value is "vault/item/field". Set OP_SERVICE_ACCOUNT_TOKEN when using. -onepassword: - database_password: "" - trongrid_api_key: "" - agent_wallet_password: "" - gasfree_api_key_nile: "" - gasfree_api_secret_nile: "" - gasfree_api_key_mainnet: "" - gasfree_api_secret_mainnet: "" - -rate_limit: - api_key_refresh_interval: 60 - authenticated: "1000/minute" - anonymous: "10/minute" - -monitoring: - port: 9001 - endpoint: "/metrics" - -facilitator: - trongrid_api_key: "" - - # Per-network config. Key = CAIP network id; listed = enabled. - # - # schemes: which payment schemes to register for the network. Defaults to - # all schemes (exact, upto, batch-settlement) when omitted. Options: exact | - # upto | batch-settlement. On TRON, - # exact also registers exact_gasfree when GasFree creds resolve. batch-settlement - # uses the facilitator's agent-wallet as the receiverAuthorizer (published via - # /supported); in production this may be a separate key. - networks: - # TRON networks accept friendly names (tron:nile / tron:mainnet / tron:shasta); - # they are normalized to canonical CAIP-2 hex chain ids (e.g. tron:0xcd8690dc) - # for the x402 SDK (1.0.1+, which uses hex chain id in CAIP-2). - tron:nile: - schemes: ["exact", "upto", "batch-settlement"] - bsc:testnet: - schemes: ["exact", "upto", "batch-settlement"] - # Enable after mainnet Permit2 / x402ExactPermit2Proxy contracts are deployed - # and addresses are filled into @x402/tron constants. - # tron:mainnet: - # schemes: ["exact", "upto", "batch-settlement"] - # bsc:mainnet: {} diff --git a/config/facilitator.config.prod.yaml b/config/facilitator.config.prod.yaml new file mode 100644 index 0000000..4c33716 --- /dev/null +++ b/config/facilitator.config.prod.yaml @@ -0,0 +1,59 @@ +# Local runtime config for the production environment. +# Start with: +# FACILITATOR_SERVICE_ENV=prod npm start + +database: + # node-postgres accepts this legacy SQLAlchemy/asyncpg URL form; credentials + # are injected from onepassword below before connecting. + url: "postgresql+asyncpg://sunpump-sunagent-postsql-v2.c9w4kaa66fyg.us-east-1.rds.amazonaws.com:5432/x402_facilitator_prod" + ssl_mode: "require" + max_open_conns: 100 + +onepassword: + database_user: "x402-facilitator/psql/user" + database_password: "x402-facilitator/psql/password" + trongrid_api_key: "x402-facilitator/trongrid/trongrid_api_key" + gasfree_api_key_nile: "x402-facilitator/gasfree/gasfree_api_key_nile" + gasfree_api_secret_nile: "x402-facilitator/gasfree/gasfree_api_secret_nile" + gasfree_api_key_mainnet: "x402-facilitator/gasfree/gasfree_api_key_mainnet" + gasfree_api_secret_mainnet: "x402-facilitator/gasfree/gasfree_api_secret_mainnet" + agent_wallet_password: "x402-facilitator/wallet/agent_wallet_password" + +server: + host: "0.0.0.0" + port: 8001 + +logging: + dir: "logs" + filename: "app.log" + level: "INFO" + +rate_limit: + api_key_refresh_interval: 5 + authenticated: "1000/minute" + anonymous: "1/minute" + +monitoring: + port: 9001 + endpoint: "/metrics" + +facilitator: + networks: + # TRON Nile testnet + tron:0xcd8690dc: + schemes: ["exact", "upto", "batch-settlement"] + # TRON Mainnet + tron:0x2b6653dc: + schemes: ["exact", "upto", "batch-settlement"] + # BSC Testnet + eip155:97: + schemes: ["exact", "upto", "batch-settlement"] + # BSC Mainnet + eip155:56: + schemes: ["exact", "upto", "batch-settlement"] + # Base Sepolia testnet + eip155:84532: + schemes: ["exact", "upto", "batch-settlement"] + # Base Mainnet + eip155:8453: + schemes: ["exact", "upto", "batch-settlement"] diff --git a/package-lock.json b/package-lock.json index ba0d5f4..85e8860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@bankofai/x402-evm": "^1.0.1", "@bankofai/x402-extensions": "^1.0.1", "@bankofai/x402-tron": "^1.0.1", - "@hono/node-server": "^1.13.0", + "@hono/node-server": "^2.0.12", "drizzle-orm": "^0.45.2", "hono": "^4.6.0", "hono-rate-limiter": "0.5.3", @@ -28,7 +28,7 @@ "devDependencies": { "@types/node": "^22.13.4", "@types/pg": "^8.11.10", - "eslint": "^9.39.5", + "eslint": "^10.8.0", "tsx": "^4.21.0", "typescript": "^5.7.3", "typescript-eslint": "^8.64.0", @@ -624,136 +624,77 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hono/node-server": { - "version": "1.19.14", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1265,9 +1206,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1282,9 +1220,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1299,9 +1234,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1316,9 +1248,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1333,9 +1262,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1350,9 +1276,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1367,9 +1290,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1384,9 +1304,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1401,9 +1318,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1418,9 +1332,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1435,9 +1346,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1452,9 +1360,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1469,9 +1374,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1667,6 +1569,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "dev": true, @@ -1885,45 +1794,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1979,19 +1849,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/expect": { "version": "3.2.6", "dev": true, @@ -2169,35 +2026,12 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/apg-js": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", "license": "BSD-2-Clause" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -2225,11 +2059,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base-x": { "version": "5.0.1", @@ -2247,14 +2084,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/bs58": { @@ -2293,16 +2132,6 @@ "node": ">= 0.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.3.3", "dev": true, @@ -2318,23 +2147,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chardet": { "version": "2.1.1", "license": "MIT" @@ -2364,26 +2176,6 @@ "node": ">=0.10.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2396,13 +2188,6 @@ "node": ">= 0.8" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2710,33 +2495,33 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2746,8 +2531,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2755,7 +2539,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -2770,30 +2554,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2824,18 +2610,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3026,9 +2812,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -3222,19 +3008,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/google-protobuf": { "version": "3.21.4", "license": "(BSD-3-Clause AND Apache-2.0)" @@ -3251,16 +3024,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3301,7 +3064,9 @@ } }, "node_modules/hono": { - "version": "4.12.26", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3357,23 +3122,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3464,29 +3212,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3547,13 +3272,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loupe": { "version": "3.2.1", "dev": true, @@ -3598,16 +3316,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -3622,7 +3343,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3782,19 +3505,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3918,7 +3628,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -3936,7 +3648,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4051,16 +3763,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rollup": { "version": "4.62.0", "dev": true, @@ -4188,19 +3890,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "dev": true, @@ -4212,19 +3901,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tdigest": { "version": "0.1.2", "license": "MIT", diff --git a/package.json b/package.json index 2a58f9d..6fbb1fe 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@bankofai/x402-evm": "^1.0.1", "@bankofai/x402-extensions": "^1.0.1", "@bankofai/x402-tron": "^1.0.1", - "@hono/node-server": "^1.13.0", + "@hono/node-server": "^2.0.12", "drizzle-orm": "^0.45.2", "hono": "^4.6.0", "hono-rate-limiter": "0.5.3", @@ -37,7 +37,7 @@ "devDependencies": { "@types/node": "^22.13.4", "@types/pg": "^8.11.10", - "eslint": "^9.39.5", + "eslint": "^10.8.0", "tsx": "^4.21.0", "typescript": "^5.7.3", "typescript-eslint": "^8.64.0", diff --git a/src/config.ts b/src/config.ts index 0ac883e..fbde30e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,8 +9,10 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { parse } from "yaml"; +import { z } from "zod"; import { getSecretFromOnePassword, isUsableToken, parseOpRef } from "./onepassword.js"; import { logger, type Level } from "./logger.js"; +import { requireCanonicalNetwork } from "./network.js"; /** Payment schemes a network can enable. `exact_gasfree` (TRON) rides with `exact`. */ export type Scheme = "exact" | "upto" | "batch-settlement"; @@ -18,68 +20,124 @@ export type Scheme = "exact" | "upto" | "batch-settlement"; /** Every payment scheme — the default registered for a network when `schemes` is omitted. */ export const ALL_SCHEMES: readonly Scheme[] = ["exact", "upto", "batch-settlement"]; -export interface NetworkConfig { - /** Schemes to register for this network. Defaults to all schemes when omitted. */ - schemes?: Scheme[]; -} +const port = z.number().int().min(1).max(65_535); +const nonNegativeInt = z.number().int().nonnegative(); +const positiveInt = z.number().int().positive(); +const schemeSchema = z.enum(["exact", "upto", "batch-settlement"]); -export interface FacilitatorConfig { - server?: { host?: string; port?: number }; - logging?: { - level?: "debug" | "info" | "warn" | "error"; - /** Directory for the log file; combined with `filename`. File logging is off unless both are set. */ - dir?: string; - /** Log file name (fixed; not timestamped). Written in append mode across restarts. */ - filename?: string; - }; - database: { - url: string; - ssl_mode?: string; - max_open_conns?: number; - max_idle_conns?: number; - max_life_time?: number; - }; - onepassword?: Record & { token?: string }; - rate_limit?: { - api_key_refresh_interval?: number; - authenticated?: string; - anonymous?: string; - }; - monitoring?: { port?: number; endpoint?: string }; - facilitator: { - trongrid_api_key?: string; - networks: Record; - }; -} +const networkConfigSchema = z + .object({ schemes: z.array(schemeSchema).min(1).optional() }) + .strict() + .superRefine((network, ctx) => { + if (network.schemes && new Set(network.schemes).size !== network.schemes.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["schemes"], message: "must not contain duplicates" }); + } + }); -const DEFAULT_PATH = process.env.FACILITATOR_CONFIG_PATH - ? resolve(process.env.FACILITATOR_CONFIG_PATH) - : resolve(process.cwd(), "config/facilitator.config.yaml"); +const facilitatorConfigSchema = z + .object({ + server: z.object({ host: z.string().min(1).optional(), port: port.optional() }).strict().optional(), + logging: z + .object({ + level: z + .string() + .transform((value) => value.toLowerCase()) + .pipe(z.enum(["debug", "info", "warn", "error"])) + .optional(), + dir: z.string().min(1).optional(), + filename: z.string().min(1).optional(), + }) + .strict() + .optional(), + database: z + .object({ + url: z.string().min(1), + ssl_mode: z.enum(["disable", "require", "verify-ca", "verify-full"]).optional(), + max_open_conns: positiveInt.optional(), + max_idle_conns: nonNegativeInt.optional(), + max_life_time: positiveInt.optional(), + }) + .strict(), + onepassword: z.record(z.string(), z.string().optional()).optional(), + rate_limit: z + .object({ + api_key_refresh_interval: positiveInt.optional(), + authenticated: z.string().min(1).optional(), + anonymous: z.string().min(1).optional(), + }) + .strict() + .optional(), + monitoring: z.object({ port: port.optional(), endpoint: z.string().startsWith("/").optional() }).strict().optional(), + facilitator: z + .object({ + trongrid_api_key: z.string().min(1).optional(), + networks: z.record(z.string(), networkConfigSchema).refine((networks) => Object.keys(networks).length > 0, { + message: "must be a non-empty object", + }), + }) + .strict(), + }) + .strict() + .superRefine((cfg, ctx) => { + const { max_open_conns: open, max_idle_conns: idle } = cfg.database; + if (open !== undefined && idle !== undefined && idle > open) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["database", "max_idle_conns"], + message: "must not exceed database.max_open_conns", + }); + } + }); + +export type NetworkConfig = z.infer; +export type FacilitatorConfig = z.infer; + +/** Resolve the runtime config path from an explicit path or service environment. */ +export function configPath(): string { + if (process.env.FACILITATOR_CONFIG_PATH) { + return resolve(process.env.FACILITATOR_CONFIG_PATH); + } + + switch (process.env.FACILITATOR_SERVICE_ENV) { + case undefined: + case "": + throw new Error( + "Configuration source is required. Set FACILITATOR_SERVICE_ENV=dev|prod or FACILITATOR_CONFIG_PATH=/path/to/config.yaml", + ); + case "dev": + return resolve(process.cwd(), "config/facilitator.config.dev.yaml"); + case "prod": + return resolve(process.cwd(), "config/facilitator.config.prod.yaml"); + default: + throw new Error("FACILITATOR_SERVICE_ENV must be either 'dev' or 'prod'"); + } +} /** * Load, parse and validate the facilitator YAML config from disk. * - * @param path - Optional explicit path; defaults to FACILITATOR_CONFIG_PATH or config/facilitator.config.yaml. + * @param path - Optional explicit path; otherwise uses FACILITATOR_CONFIG_PATH, + * FACILITATOR_SERVICE_ENV or FACILITATOR_CONFIG_PATH. * @returns The parsed configuration object. */ -export function loadConfig(path: string = DEFAULT_PATH): FacilitatorConfig { +export function loadConfig(path: string = configPath()): FacilitatorConfig { const raw = readFileSync(path, "utf8"); - const cfg = (parse(raw) as FacilitatorConfig) ?? ({} as FacilitatorConfig); - validateRequired(cfg); - return cfg; -} - -/** Force a startup failure when critical config is missing (mirrors v1 _validate_required). */ -function validateRequired(cfg: FacilitatorConfig): void { - const errors: string[] = []; - if (!cfg.database?.url) errors.push("database.url is required and must be non-empty"); - const networks = cfg.facilitator?.networks; - if (!networks || typeof networks !== "object" || Object.keys(networks).length === 0) { - errors.push("facilitator.networks is required and must be a non-empty object"); + const parsed = facilitatorConfigSchema.safeParse(parse(raw)); + if (!parsed.success) { + const errors = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`) + .join("; "); + throw new Error(`Configuration validation failed. ${errors}`); } - if (errors.length) { - throw new Error("Configuration validation failed. " + errors.join(" ")); + const cfg = parsed.data; + for (const network of Object.keys(cfg.facilitator.networks)) { + try { + requireCanonicalNetwork(network); + } catch (err) { + throw new Error(`Configuration validation failed. facilitator.networks.${network}: ${String(err)}`); + } } + return cfg; } /** List of enabled CAIP network ids from config (listed = enabled). */ @@ -130,22 +188,74 @@ export async function injectAgentWalletPasswordEnv(cfg: FacilitatorConfig): Prom } } -/** Database password from 1Password (local dev puts it directly in database.url). */ -async function getDatabasePassword(cfg: FacilitatorConfig): Promise { - return resolveOpField(cfg, "database_password"); +/** Inject resolved database credentials into a connection URL. */ +export function databaseUrlWithCredentials( + rawUrl: string, + user?: string, + password?: string, +): string { + if (!user && !password) return rawUrl; + const url = new URL(rawUrl); + if (user) url.username = user; + if (password) url.password = password; + return url.toString(); } -/** Database URL with the resolved password injected into the userinfo (if any). */ +/** Database URL with resolved 1Password credentials injected into the userinfo. */ export async function getDatabaseUrl(cfg: FacilitatorConfig): Promise { const rawUrl = cfg.database?.url; if (!rawUrl) throw new Error("database.url is required"); - const password = await getDatabasePassword(cfg); - if (!password) return rawUrl; + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error("database.url must be a valid connection URL before resolving credentials"); + } + + const urlHasUser = Boolean(url.username); + const urlHasPassword = Boolean(url.password); + const userRef = cfg.onepassword?.database_user; + const passwordRef = cfg.onepassword?.database_password; + if (urlHasUser && urlHasPassword) return rawUrl; + if (urlHasUser || urlHasPassword) { + if (userRef || passwordRef) { + throw new Error( + "database.url userinfo cannot be combined with onepassword database credential references", + ); + } + return rawUrl; + } + if (!userRef && !passwordRef) return rawUrl; + if (!userRef || !passwordRef) { + throw new Error( + "onepassword.database_user and onepassword.database_password must be configured together when database.url has no credentials", + ); + } + + const user = await resolveRequiredDatabaseSecret(cfg, "database_user", userRef); + const password = await resolveRequiredDatabaseSecret(cfg, "database_password", passwordRef); + return databaseUrlWithCredentials(rawUrl, user, password); +} - const u = new URL(rawUrl); - // URL setter handles percent-encoding of special chars in the password. - u.password = password; - return u.toString(); +/** Resolve a database credential reference, failing before a pool is created. */ +async function resolveRequiredDatabaseSecret( + cfg: FacilitatorConfig, + key: "database_user" | "database_password", + value: string, +): Promise { + const ref = parseOpRef(value); + if (!ref) throw new Error(`onepassword.${key} must be a vault/item/field reference`); + const token = opToken(cfg); + if (!isUsableToken(token)) { + throw new Error(`onepassword.${key} requires a valid OP_SERVICE_ACCOUNT_TOKEN or onepassword.token`); + } + try { + const secret = await getSecretFromOnePassword(ref, token); + if (!secret) throw new Error("resolved to an empty value"); + return secret; + } catch (err) { + throw new Error(`Unable to resolve onepassword.${key}: ${err instanceof Error ? err.message : "provider error"}`); + } } /** GasFree Open API credentials for a network. Env per-suffix/global first, then 1Password. */ diff --git a/src/facilitator.ts b/src/facilitator.ts index e812ae2..bfe4594 100644 --- a/src/facilitator.ts +++ b/src/facilitator.ts @@ -32,11 +32,7 @@ import { buildTronAuthorizerSigner, buildEvmAuthorizerSigner, } from "./signer.js"; -import { - normalize, - familyOf, - type CanonicalNetwork, -} from "./network.js"; +import { requireCanonicalNetwork, familyOf, type CanonicalNetwork } from "./network.js"; import { type FacilitatorConfig, type Scheme, @@ -186,21 +182,11 @@ export async function buildFacilitator( const networks = cfg.facilitator.networks ?? {}; const evmGasSponsoringSigners: Record = {}; - const seen = new Set(); for (const network of enabledNetworks(cfg)) { const net = networks[network]; - // Normalize once: any registered input (alias or canonical) resolves to the - // canonical CAIP-2. Unknown inputs throw here so misconfiguration fails at - // startup (P0-04). - const caip = normalize(network); - if (seen.has(caip)) { - // Reject alias+canonical of the same chain (and any duplicate) instead of - // registering twice (P1-10). - throw new Error( - `Duplicate network configuration: ${network} normalizes to ${caip}, which is already configured`, - ); - } - seen.add(caip); + // Config uses canonical CAIP-2 identifiers directly; validation does not + // rewrite the value before handing it to the SDK. + const caip = requireCanonicalNetwork(network); const setup: NetworkSetup = { facilitator, diff --git a/src/network.ts b/src/network.ts index a17c40c..095c4c1 100644 --- a/src/network.ts +++ b/src/network.ts @@ -1,16 +1,9 @@ /** * Unified network registry (P2-01). * - * Single source of truth for every supported network: canonical CAIP-2, friendly - * aliases, chain family, RPC, and chain id. Config may use any registered form - * (alias or canonical); everything is normalized to the canonical key once, and - * all downstream code (signer construction, scheme registration, /supported, - * GasFree wiring) operates exclusively on canonical identifiers. - * - * This replaces the scattered TRON_CAIP_ALIASES / EVM_CHAINS / toCaip / isTron / - * isEvm logic, closing P0-04 (canonical ids weren't accepted) and P1-10 (alias + - * canonical of the same chain registered twice) by resolving any input to one - * canonical key before registration. + * Single source of truth for every supported canonical CAIP-2 network, its chain + * family, RPC, and chain id. Configuration uses these identifiers directly, so + * they flow unchanged to signers, scheme registration, /supported, and GasFree. */ import { TRON_MAINNET, TRON_NILE, TRON_SHASTA } from "@bankofai/x402-tron"; @@ -20,17 +13,12 @@ export type NetworkFamily = "tron" | "evm"; * Canonical CAIP-2 identifier (e.g. "eip155:97", "tron:0xcd8690dc"). * * Typed as a template-literal so it is assignable to the SDK's `Network` - * (`${string}:${string}`) without a cast, while still reading as "the normalized - * form" at call sites — only `normalize()` produces this type. + * (`${string}:${string}`) without a cast. */ export type CanonicalNetwork = `${string}:${string}`; -/** Raw network id as written in config (alias or canonical, unvalidated). */ -export type ConfigNetwork = string; - interface NetworkEntry { canonical: CanonicalNetwork; - aliases: readonly string[]; family: NetworkFamily; rpc: string; /** Numeric chain id for EVM; undefined for TRON. */ @@ -44,75 +32,78 @@ interface NetworkEntry { const REGISTRY: readonly NetworkEntry[] = [ { canonical: TRON_MAINNET as CanonicalNetwork, - aliases: ["tron:mainnet"], family: "tron", rpc: "https://api.trongrid.io", }, { canonical: TRON_NILE as CanonicalNetwork, - aliases: ["tron:nile"], family: "tron", rpc: "https://nile.trongrid.io", }, { canonical: TRON_SHASTA as CanonicalNetwork, - aliases: ["tron:shasta"], family: "tron", rpc: "https://api.shasta.trongrid.io", }, { canonical: "eip155:97" as CanonicalNetwork, - aliases: ["bsc:testnet"], family: "evm", rpc: "https://bsc-testnet-rpc.publicnode.com", chainId: 97, }, { canonical: "eip155:56" as CanonicalNetwork, - aliases: ["bsc:mainnet"], family: "evm", rpc: "https://bsc-rpc.publicnode.com", chainId: 56, }, + { + canonical: "eip155:8453" as CanonicalNetwork, + family: "evm", + rpc: "https://mainnet.base.org", + chainId: 8453, + }, + { + canonical: "eip155:84532" as CanonicalNetwork, + family: "evm", + rpc: "https://sepolia.base.org", + chainId: 84532, + }, ]; -/** Lookup table: every accepted input (canonical + aliases) -> entry. */ -const BY_INPUT: Map = (() => { +/** Lookup table of supported canonical CAIP-2 identifiers. */ +const BY_CAIP: Map = (() => { const m = new Map(); - for (const e of REGISTRY) { - m.set(e.canonical, e); - for (const a of e.aliases) m.set(a, e); - } + for (const e of REGISTRY) m.set(e.canonical, e); return m; })(); /** - * Normalize any registered input (alias or canonical) to its canonical CAIP-2. - * Throws on unknown networks so misconfiguration fails fast at startup. + * Validate a canonical CAIP-2 identifier from configuration. The returned value is + * exactly the input; no alias resolution or identifier conversion is performed. */ -export function normalize(input: ConfigNetwork): CanonicalNetwork { - const entry = BY_INPUT.get(input); - if (!entry) throw new Error(`Unsupported or unknown network: ${input}`); - return entry.canonical; +export function requireCanonicalNetwork(input: string): CanonicalNetwork { + if (!BY_CAIP.has(input)) throw new Error(`Unsupported canonical CAIP-2 network: ${input}`); + return input as CanonicalNetwork; } -/** Chain family for a canonical network, or null if unregistered. */ +/** Chain family for a supported canonical network. */ export function familyOf(canonical: CanonicalNetwork): NetworkFamily { - const entry = BY_INPUT.get(canonical); + const entry = BY_CAIP.get(canonical); if (!entry) throw new Error(`Unsupported or unknown network: ${canonical}`); return entry.family; } -/** RPC endpoint for a canonical network. */ +/** RPC endpoint for a supported canonical network. */ export function rpcFor(canonical: CanonicalNetwork): string { - const entry = BY_INPUT.get(canonical); + const entry = BY_CAIP.get(canonical); if (!entry) throw new Error(`Unsupported or unknown network: ${canonical}`); return entry.rpc; } /** Numeric chain id for an EVM canonical network (undefined for TRON). */ export function chainIdOf(canonical: CanonicalNetwork): number | undefined { - const entry = BY_INPUT.get(canonical); + const entry = BY_CAIP.get(canonical); if (!entry) throw new Error(`Unsupported or unknown network: ${canonical}`); return entry.chainId; } diff --git a/src/runtime.ts b/src/runtime.ts index 6776bec..dc5881c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -71,8 +71,23 @@ export async function initSecrets(cfg: FacilitatorConfig): Promise { } /** Stage 3: database pool. */ +export function redactDatabaseUrl(databaseUrl: string): string { + try { + const url = new URL(databaseUrl); + if (url.password) url.password = "***"; + const sensitiveKeys = new Set(["password", "pass", "pwd", "token", "secret", "api_key", "apikey"]); + for (const [key] of url.searchParams) { + if (sensitiveKeys.has(key.toLowerCase())) url.searchParams.set(key, "***"); + } + return url.toString(); + } catch { + return ""; + } +} + export async function initDb(cfg: FacilitatorConfig): Promise { const databaseUrl = await getDatabaseUrl(cfg); + logger.info("Initializing database", { url: redactDatabaseUrl(databaseUrl) }); await initDatabase({ url: databaseUrl, poolSize: databaseMaxIdleConns(cfg), diff --git a/src/server.ts b/src/server.ts index 5a3ffaf..e73f4c6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -177,7 +177,6 @@ export function createApp(facilitator: x402Facilitator, deps: AppDeps): Hono<{ V return c.json({ success: false, errorReason: "missing_parameters" }, 400); } const { paymentPayload, paymentRequirements } = parsed; - const requirements = paymentRequirements; const accepted = paymentPayload.accepted; const network = requirements.network ?? accepted?.network ?? ""; diff --git a/src/signer.ts b/src/signer.ts index 212bfc7..41c78ef 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -46,8 +46,7 @@ type SignerWallet = Wallet & { * internally from the network id; `rpcUrl` pins our fullHost and `apiKey` * forwards the optional TronGrid key. * - * @param network - Config network id (e.g. "tron:nile"); normalized to canonical - * CAIP-2 (hex chain id) before being handed to the SDK. + * @param network - Canonical CAIP-2 network id (e.g. "tron:0xcd8690dc"). * @returns A FacilitatorTronSigner bound to that network's TronWeb host. */ export async function buildTronFacilitatorSigner(canonical: CanonicalNetwork): Promise { @@ -66,7 +65,7 @@ export async function buildTronFacilitatorSigner(canonical: CanonicalNetwork): P * network id; `rpcUrl` pins our endpoint. Signing is delegated to the active * agent-wallet (symmetric with TRON). * - * @param network - Config network id (e.g. "bsc:testnet"). + * @param network - Canonical CAIP-2 network id (e.g. "eip155:97"). * @returns A FacilitatorEvmSigner for that chain. */ export async function buildEvmFacilitatorSigner( @@ -85,7 +84,7 @@ export async function buildEvmFacilitatorSigner( // The SDK derives the chainId from the CAIP-2 reference (eip155:) and // resolves chain metadata from its KNOWN_CHAINS table (BSC 56/97 included), - // using `rpcUrl` for the transport. The wallet signs the built tx — no raw key + // using the registry RPC endpoint for the transport. The wallet signs the built tx — no raw key // in the SDK — and the gas-sponsoring `sendTransactions` capability rides along. return createFacilitatorEvmSigner(wallet, { network: `eip155:${chainId}`, diff --git a/test/config.test.ts b/test/config.test.ts index 5ce6edf..30da31c 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,8 +1,16 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { describe, expect, it, afterEach } from "vitest"; -import { loadConfig, enabledNetworks, getDatabaseUrl, serverPort } from "../src/config.js"; +import { + configPath, + loadConfig, + enabledNetworks, + getDatabaseUrl, + databaseUrlWithCredentials, + serverPort, +} from "../src/config.js"; +import { redactDatabaseUrl } from "../src/runtime.js"; function writeConfig(body: string): string { const dir = mkdtempSync(join(tmpdir(), "facilitator-cfg-")); @@ -16,28 +24,35 @@ database: url: "postgresql://user@localhost:5432/db" facilitator: networks: - tron:nile: + tron:0xcd8690dc: schemes: ["exact", "upto", "batch-settlement"] - bsc:testnet: + eip155:97: schemes: ["exact"] `; describe("loadConfig", () => { it("loads and lists enabled networks", () => { const cfg = loadConfig(writeConfig(VALID)); - expect(enabledNetworks(cfg)).toEqual(["tron:nile", "bsc:testnet"]); + expect(enabledNetworks(cfg)).toEqual(["tron:0xcd8690dc", "eip155:97"]); + }); + + it("loads built-in configs with uppercase logging levels", () => { + for (const environment of ["dev", "prod"]) { + const cfg = loadConfig(resolve(process.cwd(), `config/facilitator.config.${environment}.yaml`)); + expect(cfg.logging?.level).toBe("info"); + } }); it("throws when database.url is missing", () => { - expect(() => loadConfig(writeConfig(`facilitator:\n networks:\n tron:nile: {}\n`))).toThrow( - /database.url is required/, + expect(() => loadConfig(writeConfig(`facilitator:\n networks:\n tron:0xcd8690dc: {}\n`))).toThrow( + /database: Required/, ); }); it("throws when facilitator.networks is empty", () => { expect(() => loadConfig(writeConfig(`database:\n url: "x"\nfacilitator:\n networks: {}\n`)), - ).toThrow(/facilitator.networks is required/); + ).toThrow(/facilitator.networks: must be a non-empty object/); }); it("parses a per-network schemes list (absent on networks that omit it)", () => { @@ -47,18 +62,114 @@ database: url: "x" facilitator: networks: - tron:nile: + tron:0xcd8690dc: schemes: ["exact", "upto", "batch-settlement"] - bsc:testnet: {} + eip155:97: {} `), ); - expect(cfg.facilitator.networks["tron:nile"].schemes).toEqual([ + expect(cfg.facilitator.networks["tron:0xcd8690dc"].schemes).toEqual([ "exact", "upto", "batch-settlement", ]); - // Omitted → undefined; the build step defaults it to ["exact"]. - expect(cfg.facilitator.networks["bsc:testnet"].schemes).toBeUndefined(); + // Omitted → undefined; the build step defaults it to all supported schemes. + expect(cfg.facilitator.networks["eip155:97"].schemes).toBeUndefined(); + }); + + it("rejects invalid schemes, duplicate schemes, unsupported networks, and unknown keys", () => { + expect(() => + loadConfig( + writeConfig(` +database: + url: "x" +facilitator: + networks: + eip155:97: + schemes: ["excat", "exact"] +`), + ), + ).toThrow(/facilitator.networks.eip155:97.schemes.0/); + + expect(() => + loadConfig( + writeConfig(` +database: + url: "x" +facilitator: + networks: + eip155:97: + schemes: ["exact", "exact"] +`), + ), + ).toThrow(/must not contain duplicates/); + + expect(() => + loadConfig( + writeConfig(` +database: + url: "x" +facilitator: + networks: + eip155:1: {} +`), + ), + ).toThrow(/Unsupported canonical CAIP-2 network/); + + expect(() => + loadConfig( + writeConfig(` +database: + url: "x" + password: "not-supported" +facilitator: + networks: + eip155:97: {} +`), + ), + ).toThrow(/database: Unrecognized key/); + }); + +}); + +describe("configPath", () => { + const serviceEnv = process.env.FACILITATOR_SERVICE_ENV; + const explicitPath = process.env.FACILITATOR_CONFIG_PATH; + + afterEach(() => { + if (serviceEnv === undefined) delete process.env.FACILITATOR_SERVICE_ENV; + else process.env.FACILITATOR_SERVICE_ENV = serviceEnv; + if (explicitPath === undefined) delete process.env.FACILITATOR_CONFIG_PATH; + else process.env.FACILITATOR_CONFIG_PATH = explicitPath; + }); + + it("selects the development config for FACILITATOR_SERVICE_ENV=dev", () => { + delete process.env.FACILITATOR_CONFIG_PATH; + process.env.FACILITATOR_SERVICE_ENV = "dev"; + expect(configPath()).toBe(resolve(process.cwd(), "config/facilitator.config.dev.yaml")); + }); + + it("selects the production config for FACILITATOR_SERVICE_ENV=prod", () => { + delete process.env.FACILITATOR_CONFIG_PATH; + process.env.FACILITATOR_SERVICE_ENV = "prod"; + expect(configPath()).toBe(resolve(process.cwd(), "config/facilitator.config.prod.yaml")); + }); + + it("prefers FACILITATOR_CONFIG_PATH over FACILITATOR_SERVICE_ENV", () => { + process.env.FACILITATOR_SERVICE_ENV = "prod"; + process.env.FACILITATOR_CONFIG_PATH = "config/custom.yaml"; + expect(configPath()).toBe(resolve("config/custom.yaml")); + }); + + it("requires an explicit configuration source", () => { + delete process.env.FACILITATOR_SERVICE_ENV; + delete process.env.FACILITATOR_CONFIG_PATH; + expect(configPath).toThrow(/Configuration source is required/); + }); + + it("rejects an unsupported FACILITATOR_SERVICE_ENV", () => { + delete process.env.FACILITATOR_CONFIG_PATH; + process.env.FACILITATOR_SERVICE_ENV = "staging"; + expect(configPath).toThrow(/must be either 'dev' or 'prod'/); }); }); @@ -68,21 +179,74 @@ describe("getDatabaseUrl", () => { expect(await getDatabaseUrl(cfg)).toBe("postgresql://user@localhost:5432/db"); }); - it("ignores a literal database.password field (only the 1Password ref injects)", async () => { - // The redundant `database.password` literal was dropped; the password is now - // resolved solely from the `onepassword.database_password` ref (absent here, - // so the url is returned unchanged). Local dev embeds the password in the url. - const cfg = loadConfig( - writeConfig(` + it("does not resolve 1Password when the URL already has complete credentials", async () => { + const cfg = loadConfig(writeConfig(` database: - url: "postgresql://user@localhost:5432/db" - password: "p@ss" + url: "postgresql://user:password@localhost:5432/db" +onepassword: + database_user: "invalid" + database_password: "invalid" facilitator: networks: - tron:nile: {} -`), + tron:0xcd8690dc: {} +`)); + expect(await getDatabaseUrl(cfg)).toBe("postgresql://user:password@localhost:5432/db"); + }); + + it("rejects incomplete database credential configuration before opening a pool", async () => { + const cfg = loadConfig(writeConfig(` +database: + url: "postgresql://localhost:5432/db" +onepassword: + database_user: "vault/item/user" +facilitator: + networks: + tron:0xcd8690dc: {} +`)); + await expect(getDatabaseUrl(cfg)).rejects.toThrow(/must be configured together/); + }); + + it("rejects missing 1Password token when database credentials are required", async () => { + delete process.env.OP_SERVICE_ACCOUNT_TOKEN; + const cfg = loadConfig(writeConfig(` +database: + url: "postgresql://localhost:5432/db" +onepassword: + database_user: "vault/item/user" + database_password: "vault/item/password" +facilitator: + networks: + tron:0xcd8690dc: {} +`)); + await expect(getDatabaseUrl(cfg)).rejects.toThrow(/requires a valid OP_SERVICE_ACCOUNT_TOKEN/); + }); + + it("injects resolved 1Password database credentials into the URL", () => { + expect( + databaseUrlWithCredentials( + "postgresql+asyncpg://host:5432/x402_facilitator", + "db-user", + "p@ss/word", + ), + ).toBe("postgresql+asyncpg://db-user:p%40ss%2Fword@host:5432/x402_facilitator"); + }); +}); + +describe("redactDatabaseUrl", () => { + it("keeps the connection target visible while masking the password", () => { + expect(redactDatabaseUrl("postgresql+asyncpg://ec2-user:secret@onaws.com:5432/x402_facilitator")).toBe( + "postgresql+asyncpg://ec2-user:***@onaws.com:5432/x402_facilitator", ); - expect(await getDatabaseUrl(cfg)).toBe("postgresql://user@localhost:5432/db"); + }); + + it("masks sensitive query values without leaking a sentinel secret", () => { + const redacted = redactDatabaseUrl( + "postgresql://user:sentinel-userinfo@db.example/app?password=sentinel-query&TOKEN=sentinel-token&sslmode=require", + ); + expect(redacted).not.toContain("sentinel-userinfo"); + expect(redacted).not.toContain("sentinel-query"); + expect(redacted).not.toContain("sentinel-token"); + expect(redacted).toContain("sslmode=require"); }); }); @@ -108,7 +272,7 @@ server: port: 9999 facilitator: networks: - tron:nile: {} + tron:0xcd8690dc: {} `), ); process.env.SERVER_PORT = "8001"; @@ -125,7 +289,7 @@ server: port: 9999 facilitator: networks: - tron:nile: {} + tron:0xcd8690dc: {} `), ); expect(serverPort(cfg)).toBe(9999); diff --git a/test/facilitator.test.ts b/test/facilitator.test.ts index e6e3028..81e14dd 100644 --- a/test/facilitator.test.ts +++ b/test/facilitator.test.ts @@ -101,8 +101,8 @@ vi.mock("@bankofai/x402-extensions", () => ({ })); vi.mock("../src/signer.js", async (importOriginal) => { - // Keep the real network normalization (network.ts) so tests exercise the actual - // tron:nile -> hex CAIP-2 path; only the signer builders (real wallets) are stubbed. + // Keep the real canonical-network validation; only signer builders (real wallets) + // are stubbed. const actual = await importOriginal(); return { ...actual, @@ -126,7 +126,7 @@ describe("buildFacilitator", () => { database: { url: "postgresql://localhost/test" }, facilitator: { networks: { - "bsc:testnet": { + "eip155:97": { schemes: ["exact", "upto", "batch-settlement"], }, }, @@ -138,8 +138,7 @@ describe("buildFacilitator", () => { expect(mocks.buildEvmFacilitatorSigner).toHaveBeenCalledWith("eip155:97"); expect(facilitator.extensions).toHaveLength(1); expect(facilitator.extensions[0].key).toBe("erc20ApprovalGasSponsoring"); - // bsc:testnet resolves to eip155:97 -> signer97; the fallback signer is that - // same network-scoped signer, never an insertion-ordered arbitrary value. + // The fallback signer is network-scoped, never an insertion-ordered arbitrary value. expect(facilitator.extensions[0].signer).toBe(mocks.evmSigner97); expect((facilitator.extensions[0].signerForNetwork as (network: string) => unknown)("eip155:97")).toBe( mocks.evmSigner97, @@ -154,8 +153,8 @@ describe("buildFacilitator", () => { database: { url: "postgresql://localhost/test" }, facilitator: { networks: { - "bsc:mainnet": { schemes: ["exact"] }, - "bsc:testnet": { schemes: ["exact"] }, + "eip155:56": { schemes: ["exact"] }, + "eip155:97": { schemes: ["exact"] }, }, }, }, @@ -171,8 +170,7 @@ describe("buildFacilitator", () => { expect(() => resolve("eip155:999")).toThrow(/No gas-sponsoring signer registered for network eip155:999/); }); - it("P0-04: accepts canonical CAIP-2 ids in config (not just friendly aliases)", async () => { - // Writing the canonical form directly must start up just like the alias form. + it("accepts canonical CAIP-2 ids in config", async () => { const facilitator = (await buildFacilitator( { database: { url: "postgresql://localhost/test" }, @@ -184,9 +182,29 @@ describe("buildFacilitator", () => { expect(facilitator.extensions).toHaveLength(1); }); - it("P1-10: rejects alias + canonical of the same chain as a duplicate", async () => { - // bsc:testnet and eip155:97 normalize to the same canonical key; the second - // must be rejected at startup rather than registered twice. + it("registers Base Sepolia exact with the registry RPC", async () => { + const facilitator = (await buildFacilitator( + { + database: { url: "postgresql://localhost/test" }, + facilitator: { + networks: { + "eip155:84532": { + schemes: ["exact"], + }, + }, + }, + }, + { gasfreeBaseUrlFor: () => null }, + )) as InstanceType; + + expect(mocks.buildEvmFacilitatorSigner).toHaveBeenCalledWith("eip155:84532"); + expect(facilitator.registrations).toEqual([ + expect.objectContaining({ network: "eip155:84532" }), + ]); + expect(facilitator.extensions).toHaveLength(1); + }); + + it("rejects a non-canonical network id at startup", async () => { await expect( buildFacilitator( { @@ -194,16 +212,15 @@ describe("buildFacilitator", () => { facilitator: { networks: { "bsc:testnet": { schemes: ["exact"] }, - "eip155:97": { schemes: ["exact"] }, }, }, }, { gasfreeBaseUrlFor: () => null }, ), - ).rejects.toThrow(/Duplicate network configuration: eip155:97 normalizes to eip155:97, which is already configured/); + ).rejects.toThrow(/Unsupported canonical CAIP-2 network: bsc:testnet/); }); - it("P0-04/P1-10: rejects an unsupported network id at startup", async () => { + it("rejects an unsupported canonical network id at startup", async () => { await expect( buildFacilitator( { @@ -212,10 +229,10 @@ describe("buildFacilitator", () => { }, { gasfreeBaseUrlFor: () => null }, ), - ).rejects.toThrow(/Unsupported or unknown network: eip155:999/); + ).rejects.toThrow(/Unsupported canonical CAIP-2 network: eip155:999/); }); - it("passes the normalized hex CAIP-2 to the TRON gasfree registration path", async () => { + it("passes the configured CAIP-2 directly to the TRON gasfree registration path", async () => { const gasfreeBaseUrlFor = vi.fn( (network: string) => (network === TRON_NILE ? "http://127.0.0.1:8001/nile" : null), ); @@ -225,15 +242,13 @@ describe("buildFacilitator", () => { database: { url: "postgresql://localhost/test" }, facilitator: { networks: { - "tron:nile": { schemes: ["exact"] }, + [TRON_NILE]: { schemes: ["exact"] }, }, }, }, { gasfreeBaseUrlFor }, ); - // toCaip normalizes "tron:nile" -> "tron:0xcd8690dc" (TRON_NILE) before it - // reaches gasfreeBaseUrlFor and the scheme registrar. expect(gasfreeBaseUrlFor).toHaveBeenCalledWith(TRON_NILE); expect(vi.mocked(registerExactTronScheme)).toHaveBeenCalledWith( expect.anything(), @@ -256,7 +271,7 @@ describe("buildFacilitator", () => { database: { url: "postgresql://localhost/test" }, facilitator: { networks: { - "tron:nile": { schemes: ["exact"] }, + [TRON_NILE]: { schemes: ["exact"] }, }, }, }, diff --git a/test/network.test.ts b/test/network.test.ts index 8bf6e76..6e857a3 100644 --- a/test/network.test.ts +++ b/test/network.test.ts @@ -1,53 +1,54 @@ /** - * P2-01 unified network registry: normalization, family lookup, and the - * canonical/alias equivalence that closes P0-04 and P1-10. + * Canonical CAIP-2 network registry. */ import { describe, expect, it } from "vitest"; -import { normalize, familyOf, rpcFor, chainIdOf } from "../src/network.js"; +import { requireCanonicalNetwork, familyOf, rpcFor, chainIdOf } from "../src/network.js"; -describe("network registry normalize", () => { +describe("network registry", () => { it.each([ - ["bsc:testnet", "eip155:97"], - ["bsc:mainnet", "eip155:56"], - ["eip155:97", "eip155:97"], - ["eip155:56", "eip155:56"], - ["tron:mainnet", "tron:0x2b6653dc"], - ["tron:nile", "tron:0xcd8690dc"], - ["tron:shasta", "tron:0x94a9059e"], - ["tron:0x2b6653dc", "tron:0x2b6653dc"], - ["tron:0xcd8690dc", "tron:0xcd8690dc"], - ])("normalizes %s -> %s (alias and canonical resolve to the same key)", (input, expected) => { - expect(normalize(input)).toBe(expected); + "eip155:97", + "eip155:56", + "eip155:8453", + "eip155:84532", + "tron:0x2b6653dc", + "tron:0xcd8690dc", + "tron:0x94a9059e", + ])("accepts supported canonical CAIP-2 %s unchanged", input => { + expect(requireCanonicalNetwork(input)).toBe(input); }); - it("throws on unknown networks (fail fast at startup)", () => { - expect(() => normalize("foo:bar")).toThrow(/Unsupported or unknown network: foo:bar/); - expect(() => normalize("eip155:999")).toThrow(/Unsupported or unknown network: eip155:999/); - expect(() => normalize("tron:unknown")).toThrow(/Unsupported or unknown network: tron:unknown/); + it("rejects aliases and unknown networks", () => { + expect(() => requireCanonicalNetwork("bsc:testnet")).toThrow(/Unsupported canonical CAIP-2 network/); + expect(() => requireCanonicalNetwork("tron:nile")).toThrow(/Unsupported canonical CAIP-2 network/); + expect(() => requireCanonicalNetwork("eip155:999")).toThrow(/Unsupported canonical CAIP-2 network/); }); }); describe("network registry family lookup", () => { it("classifies TRON networks", () => { - expect(familyOf(normalize("tron:nile"))).toBe("tron"); - expect(familyOf(normalize("tron:0x2b6653dc"))).toBe("tron"); + expect(familyOf(requireCanonicalNetwork("tron:0xcd8690dc"))).toBe("tron"); + expect(familyOf(requireCanonicalNetwork("tron:0x2b6653dc"))).toBe("tron"); }); it("classifies EVM networks", () => { - expect(familyOf(normalize("bsc:testnet"))).toBe("evm"); - expect(familyOf(normalize("eip155:56"))).toBe("evm"); + expect(familyOf(requireCanonicalNetwork("eip155:97"))).toBe("evm"); + expect(familyOf(requireCanonicalNetwork("eip155:56"))).toBe("evm"); + expect(familyOf(requireCanonicalNetwork("eip155:84532"))).toBe("evm"); }); }); describe("network registry rpc + chainId", () => { it("returns RPC endpoints", () => { - expect(rpcFor(normalize("bsc:testnet"))).toBe("https://bsc-testnet-rpc.publicnode.com"); - expect(rpcFor(normalize("tron:nile"))).toBe("https://nile.trongrid.io"); + expect(rpcFor(requireCanonicalNetwork("eip155:97"))).toBe("https://bsc-testnet-rpc.publicnode.com"); + expect(rpcFor(requireCanonicalNetwork("eip155:8453"))).toBe("https://mainnet.base.org"); + expect(rpcFor(requireCanonicalNetwork("tron:0xcd8690dc"))).toBe("https://nile.trongrid.io"); }); it("returns chainId for EVM and undefined for TRON", () => { - expect(chainIdOf(normalize("eip155:97"))).toBe(97); - expect(chainIdOf(normalize("eip155:56"))).toBe(56); - expect(chainIdOf(normalize("tron:0xcd8690dc"))).toBeUndefined(); + expect(chainIdOf(requireCanonicalNetwork("eip155:97"))).toBe(97); + expect(chainIdOf(requireCanonicalNetwork("eip155:56"))).toBe(56); + expect(chainIdOf(requireCanonicalNetwork("eip155:8453"))).toBe(8453); + expect(chainIdOf(requireCanonicalNetwork("eip155:84532"))).toBe(84532); + expect(chainIdOf(requireCanonicalNetwork("tron:0xcd8690dc"))).toBeUndefined(); }); }); diff --git a/test/signer.test.ts b/test/signer.test.ts index 9421ccc..105d86e 100644 --- a/test/signer.test.ts +++ b/test/signer.test.ts @@ -1,25 +1,14 @@ import { describe, expect, it } from "vitest"; -import { normalize } from "../src/network.js"; - -describe("normalize", () => { - it("maps EVM config aliases to canonical eip155:", () => { - expect(normalize("bsc:testnet")).toBe("eip155:97"); - expect(normalize("bsc:mainnet")).toBe("eip155:56"); - }); - - it("normalizes TRON friendly names to hex-chain-id CAIP-2 (SDK 1.0.1+)", () => { - expect(normalize("tron:mainnet")).toBe("tron:0x2b6653dc"); - expect(normalize("tron:nile")).toBe("tron:0xcd8690dc"); - expect(normalize("tron:shasta")).toBe("tron:0x94a9059e"); - }); +import { requireCanonicalNetwork } from "../src/network.js"; +describe("canonical network validation", () => { it("passes canonical CAIP-2 ids through unchanged", () => { - expect(normalize("tron:0x2b6653dc")).toBe("tron:0x2b6653dc"); - expect(normalize("eip155:97")).toBe("eip155:97"); + expect(requireCanonicalNetwork("tron:0x2b6653dc")).toBe("tron:0x2b6653dc"); + expect(requireCanonicalNetwork("eip155:97")).toBe("eip155:97"); }); - it("throws on unknown networks (fail fast at startup)", () => { - expect(() => normalize("foo:bar")).toThrow(/Unsupported or unknown network: foo:bar/); - expect(() => normalize("eip155:999")).toThrow(/Unsupported or unknown network: eip155:999/); + it("rejects aliases and unknown networks", () => { + expect(() => requireCanonicalNetwork("bsc:testnet")).toThrow(/Unsupported canonical CAIP-2 network/); + expect(() => requireCanonicalNetwork("eip155:999")).toThrow(/Unsupported canonical CAIP-2 network/); }); });