diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..34974ea --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish + +on: + push: + tags: [ 'v*.*.*' ] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # for npm provenance + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - run: npm ci + - run: npm run version-sync + - run: npm run build + - run: npm test + + - run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # MCP registry update. Confirm the actual PATCH endpoint shape against + # the current spec before flipping this on. If it fails, the npm publish + # still succeeded above. + - name: Sync MCP registry version + if: success() + env: + MCP_REGISTRY_TOKEN: ${{ secrets.MCP_REGISTRY_TOKEN }} + run: | + if [ -z "$MCP_REGISTRY_TOKEN" ]; then + echo "MCP_REGISTRY_TOKEN not set; skipping registry sync" + exit 0 + fi + curl -sS -X PATCH "https://registry.modelcontextprotocol.io/v0/servers/io.github.voidmobcom/voidmob-mcp" \ + -H "Authorization: Bearer $MCP_REGISTRY_TOKEN" \ + -H "Content-Type: application/json" \ + --data @server.json diff --git a/.gitignore b/.gitignore index deed335..a7a5a6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ .env +.mcpregistry_* +.smithery/ +docs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a849996 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 VoidMob + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9f935e3..78510c0 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,26 @@ +[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/voidmobcom-voidmob-mcp-badge.png)](https://mseep.ai/app/voidmobcom-voidmob-mcp) + # VoidMob MCP -Mobile proxies, non-VoIP SMS verifications, and global eSIMs for AI agents and MCP clients. +[![npm version](https://img.shields.io/npm/v/@voidmob/mcp)](https://www.npmjs.com/package/@voidmob/mcp) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Node](https://img.shields.io/node/v/@voidmob/mcp)](https://nodejs.org) + +Mobile proxies, non-VoIP SMS verifications, and global eSIMs - exposed as 25 tools your AI agent can call directly. ```bash npx -y @voidmob/mcp ``` -## Quick Start +## Setup -Add VoidMob to your MCP client. No auth, no config, no API key. +1. Generate an API key at https://dashboard.voidmob.com/developers/api-keys (keys are 32-char secrets prefixed `vmk_live_`). +2. Add the MCP to your client (snippets below). Provide the key as `VOIDMOB_API_KEY`. ### Claude Code ```bash -claude mcp add voidmob -- npx -y @voidmob/mcp +claude mcp add voidmob -- env VOIDMOB_API_KEY=vmk_live_... npx -y @voidmob/mcp ``` ### Cursor @@ -25,7 +32,8 @@ Add to `~/.cursor/mcp.json`: "mcpServers": { "voidmob": { "command": "npx", - "args": ["-y", "@voidmob/mcp"] + "args": ["-y", "@voidmob/mcp"], + "env": { "VOIDMOB_API_KEY": "vmk_live_..." } } } } @@ -33,121 +41,107 @@ Add to `~/.cursor/mcp.json`: ### Claude Desktop -Add to your config file: - -- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Linux: `~/.config/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\Claude\claude_desktop_config.json` (Windows), or `~/.config/Claude/claude_desktop_config.json` (Linux): ```json { "mcpServers": { "voidmob": { "command": "npx", - "args": ["-y", "@voidmob/mcp"] + "args": ["-y", "@voidmob/mcp"], + "env": { "VOIDMOB_API_KEY": "vmk_live_..." } } } } ``` -Restart your client. You now have 23 tools for mobile proxies, SMS verification, eSIM, and wallet operations. +## Try without a key (sandbox) -## Available Tools +```bash +VOIDMOB_SANDBOX=1 npx -y @voidmob/mcp +``` -### Mobile Proxies (8 tools) +Boots in-memory mocks with a $500 play-money balance. Every tool works against fake data. State resets on restart. -| Tool | Description | -|------|-------------| -| `search_proxies` | Search mobile proxy products by country or type (shared, dedicated standard, dedicated premium) | -| `purchase_proxy` | Purchase a mobile proxy and get connection credentials | -| `get_proxy_status` | Check bandwidth usage, connection details, and IP | -| `rotate_proxy` | Rotate to a new IP address (dedicated proxies only) | -| `get_proxy_lists` | Get geo-targeted proxy lists for a shared proxy | -| `create_proxy_list` | Create a new proxy list with location targeting | -| `get_openvpn_config` | Get OpenVPN configuration file for a dedicated proxy | -| `get_vless_config` | Get VLESS connection URI for a dedicated premium proxy | - -### SMS - US Non-VoIP (7 tools) +## Configuration -| Tool | Description | -|------|-------------| -| `search_sms_services` | Search US non-VoIP SMS services with verification, rental, and dedicated pricing | -| `get_sms_price` | Get all pricing tiers for a specific service | -| `rent_number` | Rent a US number - verification (20min), long-term rental (3-30 days), or dedicated (28 days) | -| `get_messages` | Check for incoming SMS messages on a rented number | -| `cancel_rental` | Cancel a rental (full refund for verification with no messages) | -| `reuse_number` | Reuse a completed number to receive another SMS | -| `toggle_auto_renew` | Toggle auto-renewal for long-term rentals and dedicated numbers | +| Env var | Purpose | Required | +|---|---|---| +| `VOIDMOB_API_KEY` | Bearer key from the dashboard | Live mode | +| `VOIDMOB_SANDBOX` | Set to `1` for mock-data mode | No | +| `VOIDMOB_DEBUG` | Set to `1` to log requests to stderr | No | +| `VOIDMOB_BASE_URL` | Override API host (advanced) | No | -### eSIM (5 tools) +## Tools -| Tool | Description | -|------|-------------| -| `search_esim_plans` | Search eSIM data plans by country, data amount, duration, or features (5G, hotspot) | -| `get_esim_plan_details` | Get full plan details including network type, speed, and activation policy | -| `purchase_esim` | Purchase an eSIM plan and get QR code, activation code, and ICCID | -| `get_esim_usage` | Check data usage and remaining balance for an active eSIM | -| `topup_esim` | Browse available top-up products or purchase a top-up for an active eSIM | +25 tools across five domains. -### Wallet (2 tools) +### Account (1) | Tool | Description | -|------|-------------| -| `get_balance` | Get wallet balance in USD and cents | -| `deposit` | Create a crypto deposit (BTC, ETH, SOL, USDT, USDC, LTC, DOGE) | +|---|---| +| `get_account` | Balance, rate limits, and account id | -### General (1 tool) +### SMS (7) | Tool | Description | -|------|-------------| -| `list_orders` | List all orders across SMS, eSIM, and proxy services with filtering | - -## Example Conversations - -These are things you can ask Claude, Cursor, or any MCP client after adding VoidMob: +|---|---| +| `search_sms_services` | List services with prices | +| `rent_number` | Rent a US number (verification / rental / dedicated) | +| `get_rental` | Read status and received messages | +| `cancel_rental` | Cancel a verification or long-term rental | +| `reuse_number` | Free or paid reuse of a completed verification | +| `re_rent_rental` | Extend a long-term rental for another period | +| `toggle_auto_renew` | Turn auto-renewal on or off | -> Set up a dedicated US mobile proxy and give me the credentials +### eSIM (5) -> Rotate my US dedicated proxy IP - -> Create a US-only proxy list on my shared proxy - -> Get the OpenVPN config for my US dedicated proxy - -> What VLESS connection options do I have for my premium proxy? - -> Rent me a dedicated US phone number that receives from all services - -> Set up a 7-day WhatsApp rental with auto-renew - -> Check if my verification code arrived +| Tool | Description | +|---|---| +| `search_esim_plans` | Find global data plans | +| `purchase_esim` | Buy a plan | +| `get_esim_status` | Status and data usage | +| `topup_esim` | Browse and buy top-ups | +| `get_esim_qr` | Fetch the activation QR as an inline image | -> Find eSIM plans that cover all of Europe with at least 5GB +### Proxy (10) -> Find the cheapest eSIM plan for 2 weeks in Japan with at least 5GB +| Tool | Description | +|---|---| +| `search_proxies` | List available mobile proxy plans | +| `purchase_proxy` | Buy a mobile proxy | +| `get_proxy_status` | Status, usage, and gateway credentials | +| `rotate_proxy_ip` | Rotate a dedicated proxy to a new IP | +| `renew_proxy` | Extend expiry | +| `topup_proxy` | Add data | +| `regenerate_proxy_password` | Rotate the gateway password | +| `list_proxy_lists` | List geo-targeted sub-pools | +| `create_proxy_list` | Create a geo-targeted sub-pool | +| `delete_proxy_list` | Remove a sub-pool | + +### Discovery + history (2) -> Top up my Japan eSIM with more data +| Tool | Description | +|---|---| +| `get_geo` | Cascading country/region/city/ISP for targeting | +| `list_orders` | Active SMS / eSIM / proxy orders | -> Show me all my active orders +## Example prompts -## Sandbox Mode +> Rent me a US number for Telegram verification -VoidMob MCP currently runs in **sandbox mode**. All 23 tools work, but the data is mock. +> Find an eSIM plan that covers all of Europe with at least 5GB for two weeks -- **$50 starting balance** - enough to try every tool -- **Stateful** - renting a number deducts balance, messages appear after a few seconds, deposits auto-confirm -- **Deposits auto-confirm** in ~5 seconds -- **State resets** on server restart -- **No auth required** - zero config, just `npx -y @voidmob/mcp` +> Show me my active proxies -The sandbox is designed to let you explore the full flow: deposit funds, rent a number, receive a verification code, check your balance. Everything behaves like the real API, just with mock data underneath. +> Top up esim_xxx with 5GB -## API Access +## Sharing a key across processes -Currently in sandbox mode. Join the waitlist at [voidmob.com](https://voidmob.com) for early API access. +Multiple MCP clients running simultaneously (Claude Code + Cursor + Desktop) all share the same per-account rate limit. Heavy parallel usage may hit `RATE_LIMITED`; back off and retry. ---

- Website · GitHub · X (Twitter) · MIT License + Website · Docs · MCP · API reference · MIT License

diff --git a/package-lock.json b/package-lock.json index 9aa5e0a..ac8fd38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@voidmob/mcp", - "version": "0.2.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@voidmob/mcp", - "version": "0.2.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -17,16 +17,51 @@ }, "devDependencies": { "@types/node": "^25.5.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.7" }, "engines": { "node": ">=20" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -35,6 +70,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -75,14 +117,463 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/accepts": { @@ -99,9 +590,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -131,6 +622,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -193,10 +694,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -215,6 +726,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -290,6 +808,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -337,6 +865,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -355,6 +890,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -377,14 +922,24 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "engines": { "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -429,12 +984,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -453,9 +1008,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -468,6 +1023,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -507,6 +1080,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -578,9 +1166,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -590,9 +1178,9 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -641,56 +1229,327 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -752,6 +1611,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -782,6 +1660,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -822,15 +1711,42 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -840,6 +1756,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -854,9 +1799,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -901,6 +1846,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1015,13 +1994,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1067,6 +2046,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1076,6 +2079,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1085,24 +2139,49 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1114,9 +2193,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -1138,6 +2217,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1153,6 +2400,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1160,21 +2424,21 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index 22831b8..15a6af1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@voidmob/mcp", - "version": "0.2.0", + "version": "1.0.0", "description": "Mobile proxies, non-VoIP SMS verifications, and global eSIMs for AI agents and MCP clients.", "mcpName": "io.github.voidmobcom/voidmob-mcp", "type": "module", @@ -9,7 +9,6 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "module": "./src/index.ts", "files": [ "dist", "README.md" @@ -19,7 +18,10 @@ "postbuild": "chmod +x dist/index.js", "dev": "tsc --watch", "start": "node dist/index.js", - "prepublishOnly": "npm run build" + "test": "vitest run", + "test:watch": "vitest", + "version-sync": "node scripts/check-version-sync.mjs", + "prepublishOnly": "npm run version-sync && npm run build && npm test" }, "keywords": [ "mcp", @@ -39,7 +41,7 @@ "access": "public" }, "engines": { - "node": ">=20" + "node": ">=22" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -47,6 +49,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.7" } } diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs new file mode 100644 index 0000000..8f26907 --- /dev/null +++ b/scripts/check-version-sync.mjs @@ -0,0 +1,9 @@ +// scripts/check-version-sync.mjs +import { readFileSync } from "node:fs"; +const pkg = JSON.parse(readFileSync("package.json", "utf8")); +const srv = JSON.parse(readFileSync("server.json", "utf8")); +if (pkg.version !== srv.version) { + console.error(`Version mismatch: package.json=${pkg.version} server.json=${srv.version}`); + process.exit(1); +} +console.log(`Versions in sync: ${pkg.version}`); diff --git a/scripts/integration-smoke.mjs b/scripts/integration-smoke.mjs new file mode 100644 index 0000000..ebb3291 --- /dev/null +++ b/scripts/integration-smoke.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Live integration smoke against the real VoidMob API. + * + * Drives the SAME production tool handlers the MCP registers, through a real + * HTTP client, against dashboard.voidmob.com. This is the only test layer that + * validates the hand-written Zod schemas against real API responses - the + * vitest suite runs entirely against mocks. + * + * Read-only by default (zero spend). Pass --with-purchases to run ONE SMS + * verification (cancelled for refund where possible) and ONE 1GB shared proxy + * purchase (real, mostly non-refundable spend). + * + * VOIDMOB_API_KEY=vmk_live_... node scripts/integration-smoke.mjs + * VOIDMOB_API_KEY=vmk_live_... node scripts/integration-smoke.mjs --with-purchases + * + * A Zod parse failure throws out of the handler (wrapToolErrors only catches + * HttpError/NetworkError) - the harness reports those as SCHEMA MISMATCH. + */ +import { createHttpClient } from "../dist/client/http.js"; +import { getAccountHandler } from "../dist/tools/account.js"; +import { + searchSmsServicesHandler, + rentNumberHandler, + getRentalHandler, + cancelRentalHandler, +} from "../dist/tools/sms.js"; +import { searchEsimPlansHandler } from "../dist/tools/esim.js"; +import { + searchProxiesHandler, + purchaseProxyHandler, + getProxyStatusHandler, + createProxyListHandler, + listProxyListsHandler, + deleteProxyListHandler, +} from "../dist/tools/proxy.js"; +import { getGeoHandler } from "../dist/tools/geo.js"; +import { listOrdersHandler } from "../dist/tools/orders.js"; + +const KEY = process.env.VOIDMOB_API_KEY; +if (!KEY) { + console.error("Set VOIDMOB_API_KEY"); + process.exit(1); +} +const BASE = process.env.VOIDMOB_BASE_URL ?? "https://dashboard.voidmob.com/api"; +const WITH_PURCHASES = process.argv.includes("--with-purchases"); + +const http = createHttpClient({ + apiKey: KEY, + baseUrl: BASE, + debug: process.env.VOIDMOB_DEBUG === "1", + userAgent: "voidmob-mcp-integration/smoke", +}); + +let pass = 0; +let fail = 0; +const failures = []; + +function textOf(res) { + const t = res?.content?.find((c) => c.type === "text"); + return t ? t.text : "(no text block)"; +} + +async function check(name, fn) { + try { + const res = await fn(); + if (res.isError) { + console.log(` FAIL ${name}: tool error -> ${textOf(res).split("\n")[0]}`); + failures.push(`${name}: ${textOf(res).split("\n")[0]}`); + fail++; + return null; + } + const keys = res.structuredContent ? Object.keys(res.structuredContent).join(", ") : "(none)"; + console.log(` ok ${name} [structured: ${keys}]`); + pass++; + return res; + } catch (e) { + const kind = e?.name === "ZodError" ? "SCHEMA MISMATCH" : `THREW ${e?.name}`; + const detail = e?.name === "ZodError" + ? JSON.stringify(e.issues?.slice(0, 4)) + : (e?.message ?? String(e)).slice(0, 300); + console.log(` FAIL ${name}: ${kind} -> ${detail}`); + failures.push(`${name}: ${kind} ${detail}`); + fail++; + return null; + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function readOnly() { + console.log("\n== Read-only (zero spend) =="); + await check("get_account", () => getAccountHandler(http)({})); + await check("search_sms_services", () => searchSmsServicesHandler(http)({})); + await check("search_esim_plans", () => searchEsimPlansHandler(http)({ limit: 5 })); + await check("search_proxies", () => searchProxiesHandler(http)({})); + await check("get_geo (countries)", () => getGeoHandler(http)({})); + await check("get_geo (regions)", () => getGeoHandler(http)({ country: "US" })); + await check("list_orders", () => listOrdersHandler(http)({ limit: 10 })); +} + +async function purchases() { + console.log("\n== Money ops (real spend) =="); + + // --- SMS verification (cheapest service), then cancel for refund --- + const svcRes = await check("search_sms_services (pick cheapest)", () => + searchSmsServicesHandler(http)({}), + ); + if (svcRes?.structuredContent?.services?.length) { + const services = svcRes.structuredContent.services; + const cheapest = services.reduce((m, s) => + s.quoted_price_cents < m.quoted_price_cents ? s : m, + ); + console.log(` -> cheapest service: ${cheapest.name} (${cheapest.id}) @ ${cheapest.quoted_price_cents}c`); + + const rented = await check(`rent_number(${cheapest.id}, verification)`, () => + rentNumberHandler(http)({ service_id: cheapest.id, kind: "verification" }), + ); + const verId = rented?.structuredContent?.verification?.id; + if (verId) { + console.log(` -> rented ${verId}`); + await check(`get_rental(${verId})`, () => getRentalHandler(http)({ rental_id: verId })); + // Cancel for refund. May hit CANCEL_WINDOW_NOT_OPEN cooldown; report either way. + await check(`cancel_rental(${verId})`, () => cancelRentalHandler(http)({ rental_id: verId })); + } + } + + // --- Shared proxy, smallest GB, then list create/list/delete --- + const proxRes = await check("search_proxies (shared)", () => + searchProxiesHandler(http)({ type: "shared" }), + ); + if (proxRes?.structuredContent?.proxy_plans?.length) { + const plans = proxRes.structuredContent.proxy_plans; + // Prefer a 1GB plan; otherwise smallest data_gb; otherwise cheapest. + const oneGb = plans.find((p) => p.data_gb === 1); + const target = oneGb ?? plans + .filter((p) => p.data_gb != null) + .reduce((m, p) => (p.data_gb < m.data_gb ? p : m), plans[0]); + console.log(` -> target plan: ${target.name} (${target.id}) ${target.data_gb}GB @ ${target.quoted_price_cents}c`); + + const bought = await check(`purchase_proxy(${target.id})`, () => + purchaseProxyHandler(http)({ plan_id: target.id }), + ); + const proxyId = bought?.structuredContent?.proxy?.id; + if (proxyId) { + console.log(` -> bought ${proxyId}, polling for active (up to ~2min)...`); + let active = false; + for (let i = 0; i < 12; i++) { + await sleep(10_000); + const st = await getProxyStatusHandler(http)({ proxy_id: proxyId }); + const status = st?.structuredContent?.proxy?.status; + console.log(` poll ${i + 1}: status=${status}`); + if (status === "active") { active = true; break; } + if (status === "refunded" || status === "expired") break; + } + await check(`get_proxy_status(${proxyId})`, () => getProxyStatusHandler(http)({ proxy_id: proxyId })); + if (active) { + const created = await check(`create_proxy_list(${proxyId})`, () => + createProxyListHandler(http)({ + proxy_id: proxyId, + name: "smoke-test", + location_preset: "world_mix", + rotation_period: 0, + }), + ); + await check(`list_proxy_lists(${proxyId})`, () => listProxyListsHandler(http)({ proxy_id: proxyId })); + const listId = created?.structuredContent?.list?.id; + if (listId) { + await check(`delete_proxy_list(${proxyId}, ${listId})`, () => + deleteProxyListHandler(http)({ proxy_id: proxyId, list_id: listId }), + ); + } + } else { + console.log(" (proxy never went active - skipping list create/delete)"); + } + } + } +} + +(async () => { + console.log(`Integration smoke against ${BASE}`); + console.log(`Mode: ${WITH_PURCHASES ? "READ-ONLY + PURCHASES" : "READ-ONLY"}`); + await readOnly(); + if (WITH_PURCHASES) await purchases(); + console.log(`\n== Result: ${pass} passed, ${fail} failed ==`); + if (failures.length) { + console.log("Failures:"); + for (const f of failures) console.log(` - ${f}`); + } + process.exit(fail > 0 ? 1 : 0); +})(); diff --git a/server.json b/server.json index c945d65..607d0ab 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/voidmobcom/voidmob-mcp", "source": "github" }, - "version": "0.2.0", + "version": "1.0.0", "packages": [ { "registryType": "npm", "identifier": "@voidmob/mcp", - "version": "0.2.0", + "version": "1.0.0", "transport": { "type": "stdio" } diff --git a/src/client/call-api.ts b/src/client/call-api.ts new file mode 100644 index 0000000..a927bd9 --- /dev/null +++ b/src/client/call-api.ts @@ -0,0 +1,37 @@ +import { HttpClient, HttpError } from "./http.js"; + +interface SuccessEnvelope { success: true; data: T } +interface ErrorEnvelope { + success: false; + error: { + code: string; + message: string; + request_id: string; + details?: Record; + }; +} +type ApiEnvelope = SuccessEnvelope | ErrorEnvelope; + +export async function callApi( + http: HttpClient, + method: string, + path: string, + opts?: { body?: unknown; idempotencyKey?: string }, +): Promise { + const res = await http.request(method, path, opts); + // 204 No Content (e.g. DELETE proxy list) is a success with no body. + if (res.status === 204) return undefined as T; + const env = res.body as ApiEnvelope; + if (env && env.success === true) return env.data; + // Handles mock test clients and 2xx responses carrying success:false. + if (env && env.success === false) { + throw new HttpError( + res.status, + env.error.code, + env.error.request_id, + env.error.details, + env.error.message ?? env.error.code, + ); + } + throw new HttpError(res.status, "UNKNOWN_ERROR", "", undefined, "Unexpected response shape"); +} diff --git a/src/client/debug.ts b/src/client/debug.ts new file mode 100644 index 0000000..82670a2 --- /dev/null +++ b/src/client/debug.ts @@ -0,0 +1,6 @@ +export function makeDebugLogger(enabled: boolean) { + if (!enabled) return () => {}; + return (line: string) => { + process.stderr.write(`[voidmob-mcp] ${line}\n`); + }; +} diff --git a/src/client/errors.ts b/src/client/errors.ts new file mode 100644 index 0000000..cce4df7 --- /dev/null +++ b/src/client/errors.ts @@ -0,0 +1,57 @@ +import { HttpError, NetworkError } from "./http.js"; +import { formatUsd } from "../utils/format.js"; + +const SETUP_URL = "https://dashboard.voidmob.com/developers/api-keys"; +const WALLET_URL = "https://dashboard.voidmob.com/wallet"; + +export function mapApiError(err: unknown): string { + if (err instanceof NetworkError) { + return "Could not reach dashboard.voidmob.com. Check your connection and retry."; + } + if (!(err instanceof HttpError)) { + return `Unexpected error: ${(err as Error)?.message ?? String(err)}`; + } + + const reqLine = err.requestId ? ` (request_id: ${err.requestId})` : ""; + + switch (err.code) { + case "UNAUTHENTICATED": + return `Your VOIDMOB_API_KEY is invalid or revoked. Generate a new key at ${SETUP_URL}${reqLine}`; + case "IP_NOT_ALLOWED": + return `This API key has an IP allowlist; the current IP is blocked. Update or remove the allowlist in the dashboard${reqLine}`; + case "RATE_LIMITED": + return `Rate limit hit. Retry shortly${reqLine}`; + case "INSUFFICIENT_BALANCE": + return `Insufficient balance. Top up at ${WALLET_URL}${reqLine}`; + case "DAILY_SPEND_CAP_EXCEEDED": + return `This API key's daily spend cap is reached. Raise the cap in the dashboard or wait for reset${reqLine}`; + case "PRICE_OVER_CAP": { + const max = err.details?.max_price_cents as number | undefined; + const avail = err.details?.available_price_cents as number | undefined; + if (max !== undefined && avail !== undefined) { + return `Price moved from ${formatUsd(max)} to ${formatUsd(avail)} between quote and purchase. Re-run the tool to accept the new price${reqLine}`; + } + // eSIM emits only available_price_cents (no max). Still surface the + // concrete current price rather than a vague "above your cap". + if (avail !== undefined) { + return `Price moved above your quote (now ${formatUsd(avail)}). Re-run the tool to accept the new price${reqLine}`; + } + return `Price moved above your cap. Re-run the tool to accept the new price${reqLine}`; + } + case "SERVICE_OUT_OF_STOCK": + case "OUT_OF_STOCK_AT_PRICE": + return `No stock available right now. Try again in a few minutes or pick a different service${reqLine}`; + case "CANCEL_WINDOW_NOT_OPEN": + return `Cancellation cooldown active. Try again in ~30s${reqLine}`; + case "PROVIDER_TIMEOUT": + case "PROVISIONING_FAILED": + return `Provider request timed out. Your funds were refunded. Retry${reqLine}`; + case "PROVIDER_ERROR": + return `Service error. Please retry shortly${reqLine}`; + case "INTERNAL_ERROR": + return `Unexpected error. Please retry; if this persists, contact support${reqLine}`; + default: + // Pass the API's white-labeled message through unchanged + return `${err.message ?? err.code}${reqLine}`; + } +} diff --git a/src/client/http.ts b/src/client/http.ts new file mode 100644 index 0000000..a4be7fb --- /dev/null +++ b/src/client/http.ts @@ -0,0 +1,164 @@ +import { makeDebugLogger } from "./debug.js"; + +const GET_TIMEOUT_MS = 10_000; +const WRITE_TIMEOUT_MS = 30_000; +const GET_RETRIES = 2; +const RETRY_DELAY_MS = 250; + +export interface HttpResponse { + status: number; + body?: unknown; + binary?: Buffer; + headers: Headers; +} + +export interface HttpRequestOpts { + body?: unknown; + idempotencyKey?: string; + headers?: Record; + expectBinary?: boolean; +} + +export interface HttpClient { + request( + method: string, + path: string, + opts?: HttpRequestOpts, + ): Promise; +} + +export class HttpError extends Error { + constructor( + public status: number, + public code: string, + public requestId: string, + public details?: Record, + message?: string, + ) { + super(message ?? `${code} (status ${status})`); + this.name = "HttpError"; + } +} + +export class NetworkError extends Error { + constructor(public cause: unknown) { + super("Network error reaching the VoidMob API"); + this.name = "NetworkError"; + } +} + +interface ClientOpts { + apiKey: string; + baseUrl: string; + debug: boolean; + userAgent: string; +} + +function jitter(): number { + return Math.random() * 100; +} + +export function createHttpClient(opts: ClientOpts): HttpClient { + const dbg = makeDebugLogger(opts.debug); + + async function doOnce(method: string, path: string, ropts: HttpRequestOpts): Promise { + const ac = new AbortController(); + const timeoutMs = method.toUpperCase() === "GET" ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS; + const timer = setTimeout(() => ac.abort(), timeoutMs); + const headers = new Headers({ + Authorization: `Bearer ${opts.apiKey}`, + "User-Agent": opts.userAgent, + "Content-Type": "application/json", + ...(ropts.headers ?? {}), + }); + if (ropts.idempotencyKey) headers.set("Idempotency-Key", ropts.idempotencyKey); + + const startMs = Date.now(); + let res: Response; + try { + res = (await fetch(`${opts.baseUrl}${path}`, { + method, + headers, + body: ropts.body !== undefined ? JSON.stringify(ropts.body) : undefined, + signal: ac.signal, + })) as Response; + } catch (e) { + clearTimeout(timer); + throw new NetworkError(e); + } + clearTimeout(timer); + + const elapsed = Date.now() - startMs; + + if (ropts.expectBinary && res.status >= 200 && res.status < 300) { + let ab: ArrayBuffer; + try { + ab = await res.arrayBuffer(); + } catch (e) { + throw new NetworkError(e); + } + dbg(`${method} ${path} ${res.status} (${elapsed}ms) [binary ${ab.byteLength}b]`); + return { status: res.status, binary: Buffer.from(ab), headers: res.headers }; + } + + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + + const idem = ropts.idempotencyKey ? ` idem=${ropts.idempotencyKey.slice(0, 8)}...` : ""; + const errObj = + body && typeof body === "object" && "error" in body + ? (body as Record).error + : undefined; + const codeOrEmpty = + errObj && typeof errObj === "object" && "code" in errObj && + typeof (errObj as Record).code === "string" + ? ` ${(errObj as { code: string }).code}` + : ""; + dbg(`${method} ${path}${idem} ${res.status}${codeOrEmpty} (${elapsed}ms)`); + + if (res.status >= 200 && res.status < 300) { + return { status: res.status, body, headers: res.headers }; + } + + const errBody = (body as { + error?: { code: string; message: string; request_id: string; details?: Record }; + } | undefined)?.error; + throw new HttpError( + res.status, + errBody?.code ?? "UNKNOWN_ERROR", + errBody?.request_id ?? "", + errBody?.details, + errBody?.message, + ); + } + + return { + async request(method, path, ropts = {}) { + const isGet = method.toUpperCase() === "GET"; + const maxAttempts = isGet ? 1 + GET_RETRIES : 1; + let lastErr: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await doOnce(method, path, ropts); + } catch (e) { + lastErr = e; + if (!isGet) throw e; + if (e instanceof HttpError) { + // Retry only on 5xx; never on 429 or 4xx + if (e.status < 500) throw e; + } else if (!(e instanceof NetworkError)) { + throw e; + } + if (attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS + jitter())); + } + } + } + throw lastErr; + }, + }; +} diff --git a/src/client/idempotency.ts b/src/client/idempotency.ts new file mode 100644 index 0000000..91e8a71 --- /dev/null +++ b/src/client/idempotency.ts @@ -0,0 +1,5 @@ +import { randomUUID } from "node:crypto"; + +export function newIdempotencyKey(): string { + return randomUUID(); +} diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..b90a3e9 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +// ── Common envelopes ──────────────────────────────────────────────────────── + +export const SuccessEnvelope = (inner: T) => + z.object({ success: z.literal(true), data: inner }); + +// ── /v1/me ────────────────────────────────────────────────────────────────── + +export const MePayload = z.object({ + id: z.string(), + balance: z.object({ + amount_cents: z.number().int(), + currency: z.literal("USD"), + formatted: z.string(), + }), + rate_limits: z.record( + z.string(), + z.object({ limit: z.number(), window_seconds: z.number() }), + ), + created_at: z.string(), +}); +export type MePayload = z.infer; + +// ── /v1/services ──────────────────────────────────────────────────────────── + +export const SmsService = z.object({ + id: z.string(), + name: z.string(), + icon_url: z.string().nullable().optional(), + quoted_price_cents: z.number().int(), + base_price_cents: z.number().int().optional(), + price_ceiling_cents: z.number().int().optional(), + available: z.boolean().optional(), + // Long-term rental tiers are flat fields. `0` means the tier is not offered. + ltr_3d_price_cents: z.number().int().optional(), + ltr_7d_price_cents: z.number().int().optional(), + ltr_14d_price_cents: z.number().int().optional(), + ltr_30d_price_cents: z.number().int().optional(), + // Only present on the svc_dedicated_28d row (the 28-day dedicated number tier). + ltr_28d_price_cents: z.number().int().optional(), +}); +export type SmsService = z.infer; + +export const ServicesResponse = z.object({ + country: z.string().optional(), + services: z.array(SmsService), + fetched_at: z.string().optional(), +}); + +// ── Verifications ──────────────────────────────────────────────────────────── + +export const Verification = z.object({ + id: z.string(), + display_id: z.string().nullable().optional(), + status: z.enum(["waiting_for_code", "code_received", "cancelled", "expired", "failed"]), + phone_number: z.string(), + service_id: z.string().nullable(), + service_name: z.string(), + charged_price_cents: z.number().int(), + expires_at: z.string(), + can_cancel: z.boolean(), + created_at: z.string(), + // The received SMS code (present only when status = code_received). + code: z.string().optional(), + code_received_at: z.string().optional(), + reuse_counter: z.number().int(), + allow_reuse: z.boolean(), + allow_paid_reuse: z.boolean(), + paid_reuse_price_cents: z.number().int(), + charged_reuse_cents: z.number().int().optional(), + refunded_cents: z.number().int().optional(), +}); +export type Verification = z.infer; + +// Terminal-action responses (cancel) return a slim verification object, not +// the full resource: { id, status, refunded_cents }. +export const VerificationCancelResult = z.object({ + id: z.string(), + status: z.string(), + refunded_cents: z.number().int().optional(), +}); +export type VerificationCancelResult = z.infer; + +// ── Rentals (long-term + 28-day dedicated) ────────────────────────────────── + +export const RentalMessage = z.object({ + id: z.string(), + code: z.string().nullable().optional(), + text: z.string(), + received_at: z.string(), +}); + +export const Rental = z.object({ + id: z.string(), + display_id: z.string().nullable().optional(), + status: z.enum(["active", "expired", "cancelled"]), + phone_number: z.string(), + service_id: z.string().nullable(), + service_name: z.string(), + country: z.string(), + duration: z.string(), + rental_type: z.literal("rental"), + charged_price_cents: z.number().int(), + auto_renew: z.boolean(), + next_renewal_price_cents: z.number().int(), + re_rent_available: z.boolean(), + re_rent_price_cents: z.number().int().nullable(), + re_rent_blocked_at: z.string().nullable(), + created_at: z.string(), + paid_until: z.string(), + expires_at: z.string(), + can_cancel: z.boolean(), + cancel_window_expires_at: z.string().nullable().optional(), + messages: z.array(RentalMessage).optional(), +}); +export type Rental = z.infer; + +// ── eSIM products + orders ────────────────────────────────────────────────── + +export const EsimProductFeatures = z.object({ + has_5g: z.boolean(), + has_hotspot: z.boolean(), + has_calls: z.boolean(), + has_sms: z.boolean(), + supports_topup: z.boolean(), +}); + +export const EsimProduct = z.object({ + id: z.string(), + title: z.string(), + countries: z.array(z.string()), + region: z.string().nullable(), + country_count: z.number().int(), + routing_location: z.string().nullable(), + data_limit_gb: z.number().nullable(), + data_unlimited: z.boolean(), + validity_days: z.number().int(), + features: EsimProductFeatures, + price_cents: z.number().int(), + currency: z.literal("USD"), +}); +export type EsimProduct = z.infer; + +export const Esim = z.object({ + id: z.string(), + status: z.enum(["processing", "completed", "cancelled", "refunded", "expired"]), + product_id: z.string().nullable(), + is_topup: z.boolean(), + parent_order_id: z.string().nullable(), + iccid: z.string().nullable(), + activation_code: z.string().nullable(), + qr_code_url: z.string().nullable(), + smdp_address: z.string().nullable(), + data_limit_gb: z.number().nullable(), + data_unlimited: z.boolean(), + validity_days: z.number().int(), + countries: z.array(z.string()), + routing_location: z.string().nullable(), + charged_price_cents: z.number().int(), + currency: z.literal("USD"), + created_at: z.string(), + completed_at: z.string().nullable(), + expires_at: z.string().nullable(), +}); +export type Esim = z.infer; + +export const EsimUsagePackage = z.object({ + name: z.string(), + total_mb: z.number(), + total_gb: z.number(), + used_mb: z.number(), + used_gb: z.number(), + remaining_mb: z.number(), + remaining_gb: z.number(), + percent_used: z.number(), + activation_date: z.string().nullable(), + expiration_date: z.string().nullable(), +}); + +export const EsimUsage = z.object({ + esim_id: z.string(), + esim_status: z.string(), + packages: z.array(EsimUsagePackage), +}); +export type EsimUsage = z.infer; + +// ── Proxies ────────────────────────────────────────────────────────────────── + +export const ProxyGateway = z.object({ + host: z.string(), + port: z.number().int(), + protocol: z.enum(["http", "socks5"]), + username: z.string(), + password: z.string(), + username_geo_hint: z.string().optional(), +}); + +export const ProxyCredentials = z.object({ + host: z.string(), + port: z.number().int(), + protocol: z.string(), + username: z.string(), + password: z.string(), +}); + +export const ProxyList = z.object({ + id: z.string(), + proxy_id: z.string(), + name: z.string(), + country: z.string().nullable().optional(), + countries: z.array(z.string()).nullable().optional(), + region: z.string().nullable().optional(), + city: z.string().nullable().optional(), + isp: z.string().nullable().optional(), + zip: z.string().nullable().optional(), + rotation_period_seconds: z.number().int(), + rotation_mode: z.string(), + format: z.string(), + credentials: ProxyCredentials.nullable(), + entries: z.array(z.string()), + activation_note: z.string(), + created_at: z.string(), +}); +export type ProxyList = z.infer; + +export const Proxy = z.object({ + id: z.string(), + status: z.enum(["provisioning", "active", "expired", "refunded", "exhausted"]), + plan_id: z.string().nullable().optional(), + data_gb_total: z.number().int(), + data_bytes_used: z.number().int(), + charged_price_cents: z.number().int(), + expires_at: z.string(), + gateway: ProxyGateway.nullable(), + lists: z.array(ProxyList).default([]), + rotation_url: z.string().nullable().optional(), + created_at: z.string().optional(), +}); +export type Proxy = z.infer; + +export const ProxyPlan = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(["shared"]), + country: z.string().nullable(), + country_name: z.string().nullable().optional(), + data_gb: z.number().int(), + duration_days: z.number().int(), + period: z.enum(["daily", "weekly", "monthly"]).optional(), + quoted_price_cents: z.number().int(), + available: z.boolean().optional(), +}); +export type ProxyPlan = z.infer; + +// ── Geo ───────────────────────────────────────────────────────────────────── + +export const GeoCountry = z.object({ + code: z.string(), + name: z.string(), + available_nodes: z.number().int(), +}); + +// Regions, cities, and ISPs are all GeoNode shape: { name, available_nodes }. +// No `code` field (countries are the only geo level that carry a code). +export const GeoRegion = z.object({ name: z.string(), available_nodes: z.number().int() }); +export const GeoCity = GeoRegion; +export const GeoIsp = GeoRegion; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..97b7b8d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,45 @@ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export interface Config { + sandbox: boolean; + apiKey: string | null; + baseUrl: string; + debug: boolean; +} + +const DEFAULT_BASE_URL = "https://dashboard.voidmob.com/api"; +const SETUP_URL = "https://dashboard.voidmob.com/developers/api-keys"; + +const KEY_RE = /^vmk_(live|test)_[A-Za-z0-9]{32}$/; + +export function parseEnv(env: NodeJS.ProcessEnv | Record = process.env): Config { + const sandbox = env.VOIDMOB_SANDBOX === "1"; + const debug = env.VOIDMOB_DEBUG === "1"; + const baseUrl = env.VOIDMOB_BASE_URL ?? DEFAULT_BASE_URL; + const rawKey = env.VOIDMOB_API_KEY?.trim() || null; + + if (sandbox) { + return { sandbox: true, apiKey: null, baseUrl, debug }; + } + + if (!rawKey) { + throw new ConfigError( + `Set VOIDMOB_API_KEY=vmk_live_... or VOIDMOB_SANDBOX=1.\n` + + `Generate a key at ${SETUP_URL}`, + ); + } + + if (!KEY_RE.test(rawKey)) { + throw new ConfigError( + `VOIDMOB_API_KEY format is invalid. Expected vmk_live_ or vmk_test_ followed by 32 alphanumeric characters.\n` + + `Generate a key at ${SETUP_URL}`, + ); + } + + return { sandbox: false, apiKey: rawKey, baseUrl, debug }; +} diff --git a/src/constants/rental-id.ts b/src/constants/rental-id.ts new file mode 100644 index 0000000..3a92775 --- /dev/null +++ b/src/constants/rental-id.ts @@ -0,0 +1,8 @@ +export const VER_PREFIX = "ver_"; +export const REN_PREFIX = "ren_"; + +export const isVerificationId = (id: string): boolean => id.startsWith(VER_PREFIX); +export const isRentalId = (id: string): boolean => id.startsWith(REN_PREFIX); + +export const INVALID_RENTAL_ID = (id: string): string => + `Invalid rental_id '${id}'. Expected ${VER_PREFIX}xxx (verification) or ${REN_PREFIX}xxx (long-term/dedicated).`; diff --git a/src/index.ts b/src/index.ts index 12ae29e..6d89cd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,30 @@ #!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { registerWalletTools } from "./tools/wallet.js"; -import { registerSmsTools } from "./tools/sms.js"; -import { registerEsimTools } from "./tools/esim.js"; -import { registerProxyTools } from "./tools/proxy.js"; -import { registerOrdersTools } from "./tools/orders.js"; - -function createServer() { - const server = new McpServer({ - name: "@voidmob/mcp", - version: "0.2.0", - }); - - registerWalletTools(server); - registerSmsTools(server); - registerEsimTools(server); - registerProxyTools(server); - registerOrdersTools(server); - - return server; -} - -export function createSandboxServer() { - return createServer(); -} +import { parseEnv, ConfigError } from "./config.js"; +import { buildLiveServer } from "./modes/live.js"; +import { buildSandboxServer } from "./modes/sandbox.js"; async function main() { - const server = createServer(); + let cfg; + try { + cfg = parseEnv(); + } catch (e) { + if (e instanceof ConfigError) { + process.stderr.write(`[voidmob-mcp] config error: ${e.message}\n`); + process.exit(1); + } + throw e; + } + + const server = cfg.sandbox ? buildSandboxServer() : buildLiveServer(cfg); const transport = new StdioServerTransport(); await server.connect(transport); } -main().catch(console.error); +main().catch((err) => { + process.stderr.write(`[voidmob-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`); + process.exit(1); +}); + +export { buildSandboxServer } from "./modes/sandbox.js"; +export { buildLiveServer } from "./modes/live.js"; diff --git a/src/mock-data/esim.ts b/src/mock-data/esim.ts deleted file mode 100644 index ae69253..0000000 --- a/src/mock-data/esim.ts +++ /dev/null @@ -1,90 +0,0 @@ -export interface EsimPlan { - id: string; - title: string; - countries: string[]; - dataLimitGb: number | null; - dataUnlimited: boolean; - validityDays: number; - retailPriceUsd: number; - has5g: boolean; - hasHotspot: boolean; - supportsTopup: boolean; - routingLocation: string; - networkType: string; - speed: string; - activationPolicy: string; - tags: string[]; -} - -export interface EsimTopupProduct { - id: string; - parentPlanId: string; - title: string; - dataLimitGb: number; - validityDays: number; - retailPriceUsd: number; -} - -export const esimPlans: EsimPlan[] = [ - { id: "esim_jp_3g_7d", title: "Japan 3GB / 7 Days", countries: ["JP"], dataLimitGb: 3, dataUnlimited: false, validityDays: 7, retailPriceUsd: 4.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Budget"] }, - { id: "esim_jp_5g_14d", title: "Japan 5GB / 14 Days", countries: ["JP"], dataLimitGb: 5, dataUnlimited: false, validityDays: 14, retailPriceUsd: 7.50, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Popular"] }, - { id: "esim_jp_10g_30d", title: "Japan 10GB / 30 Days", countries: ["JP"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 12.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] }, - { id: "esim_jp_unl_30d", title: "Japan Unlimited / 30 Days", countries: ["JP"], dataLimitGb: null, dataUnlimited: true, validityDays: 30, retailPriceUsd: 22.00, has5g: true, hasHotspot: false, supportsTopup: false, routingLocation: "Japan", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Unlimited"] }, - { id: "esim_us_5g_7d", title: "USA 5GB / 7 Days", countries: ["US"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 6.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: [] }, - { id: "esim_us_10g_30d", title: "USA 10GB / 30 Days", countries: ["US"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 11.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: ["Popular"] }, - { id: "esim_us_20g_30d", title: "USA 20GB / 30 Days", countries: ["US"], dataLimitGb: 20, dataUnlimited: false, validityDays: 30, retailPriceUsd: 18.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United States", networkType: "5G/LTE", speed: "Up to 150 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] }, - { id: "esim_gb_5g_7d", title: "UK 5GB / 7 Days", countries: ["GB"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.50, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United Kingdom", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: [] }, - { id: "esim_gb_10g_30d", title: "UK 10GB / 30 Days", countries: ["GB"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 10.00, has5g: true, hasHotspot: true, supportsTopup: true, routingLocation: "United Kingdom", networkType: "5G/LTE", speed: "Up to 100 Mbps", activationPolicy: "First data usage", tags: ["Popular"] }, - { id: "esim_de_5g_7d", title: "Germany 5GB / 7 Days", countries: ["DE"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] }, - { id: "esim_de_10g_30d", title: "Germany 10GB / 30 Days", countries: ["DE"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 9.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] }, - { id: "esim_th_5g_7d", title: "Thailand 5GB / 7 Days", countries: ["TH"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 3.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Thailand", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Budget"] }, - { id: "esim_th_15g_30d", title: "Thailand 15GB / 30 Days", countries: ["TH"], dataLimitGb: 15, dataUnlimited: false, validityDays: 30, retailPriceUsd: 8.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Thailand", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Best Value"] }, - { id: "esim_tr_5g_7d", title: "Turkey 5GB / 7 Days", countries: ["TR"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 4.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Turkey", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] }, - { id: "esim_br_5g_7d", title: "Brazil 5GB / 7 Days", countries: ["BR"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 5.50, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Brazil", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: [] }, - { id: "esim_eu_5g_7d", title: "Europe 30 Countries / 5GB / 7 Days", countries: ["GB", "DE", "FR", "IT", "ES", "NL", "BE", "AT", "CH", "PT", "SE", "NO", "DK", "FI", "IE", "PL", "CZ", "RO", "HU", "GR", "HR", "BG", "SK", "SI", "LT", "LV", "EE", "CY", "MT", "LU"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 8.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional", "Popular"] }, - { id: "esim_eu_10g_30d", title: "Europe 30 Countries / 10GB / 30 Days", countries: ["GB", "DE", "FR", "IT", "ES", "NL", "BE", "AT", "CH", "PT", "SE", "NO", "DK", "FI", "IE", "PL", "CZ", "RO", "HU", "GR", "HR", "BG", "SK", "SI", "LT", "LV", "EE", "CY", "MT", "LU"], dataLimitGb: 10, dataUnlimited: false, validityDays: 30, retailPriceUsd: 14.00, has5g: false, hasHotspot: true, supportsTopup: true, routingLocation: "Germany", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional", "Best Value"] }, - { id: "esim_sea_5g_7d", title: "Southeast Asia 8 Countries / 5GB / 7 Days", countries: ["TH", "VN", "MY", "SG", "ID", "PH", "KH", "LA"], dataLimitGb: 5, dataUnlimited: false, validityDays: 7, retailPriceUsd: 6.00, has5g: false, hasHotspot: true, supportsTopup: false, routingLocation: "Singapore", networkType: "4G LTE", speed: "Up to 50 Mbps", activationPolicy: "First data usage", tags: ["Regional"] }, -]; - -export const esimTopupProducts: EsimTopupProduct[] = [ - { id: "topup_1g_7d", parentPlanId: "*", title: "1GB Top-up / 7 Days", dataLimitGb: 1, validityDays: 7, retailPriceUsd: 2.00 }, - { id: "topup_3g_30d", parentPlanId: "*", title: "3GB Top-up / 30 Days", dataLimitGb: 3, validityDays: 30, retailPriceUsd: 4.50 }, - { id: "topup_5g_30d", parentPlanId: "*", title: "5GB Top-up / 30 Days", dataLimitGb: 5, validityDays: 30, retailPriceUsd: 7.00 }, - { id: "topup_10g_30d", parentPlanId: "*", title: "10GB Top-up / 30 Days", dataLimitGb: 10, validityDays: 30, retailPriceUsd: 12.00 }, -]; - -export function searchPlans(opts: { - country?: string; duration?: number; dataAmount?: number; - has5g?: boolean; hasHotspot?: boolean; search?: string; - limit?: number; -}): EsimPlan[] { - let results = esimPlans; - if (opts.country) { - const c = opts.country.toUpperCase(); - results = results.filter((p) => p.countries.includes(c)); - } - if (opts.duration) results = results.filter((p) => p.validityDays >= opts.duration!); - if (opts.dataAmount) results = results.filter((p) => p.dataUnlimited || (p.dataLimitGb !== null && p.dataLimitGb >= opts.dataAmount!)); - if (opts.has5g) results = results.filter((p) => p.has5g); - if (opts.hasHotspot) results = results.filter((p) => p.hasHotspot); - if (opts.search) { - const q = opts.search.toLowerCase(); - results = results.filter((p) => p.title.toLowerCase().includes(q)); - } - results = results.sort((a, b) => a.retailPriceUsd - b.retailPriceUsd); - return results.slice(0, opts.limit ?? 20); -} - -export function getPlan(planId: string): EsimPlan | undefined { - return esimPlans.find((p) => p.id === planId); -} - -export function getTopupProducts(planId: string): EsimTopupProduct[] { - const plan = getPlan(planId); - if (!plan || !plan.supportsTopup) return []; - return esimTopupProducts.filter((t) => t.parentPlanId === "*" || t.parentPlanId === planId); -} - -export function getTopupProduct(topupId: string): EsimTopupProduct | undefined { - return esimTopupProducts.find((t) => t.id === topupId); -} diff --git a/src/mock-data/proxy.ts b/src/mock-data/proxy.ts deleted file mode 100644 index 31b1390..0000000 --- a/src/mock-data/proxy.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ProxyType } from "../sandbox/state.js"; - -export interface ProxyProduct { - id: string; - type: ProxyType; - name: string; - country: string; - countryName: string; - carrier: string; - carrierName: string; - dataGb: number | null; - durationDays: number; - period: string; - priceCents: number; - features: string[]; -} - -export const proxyProducts: ProxyProduct[] = [ - { id: "prx_sh_us_1g", type: "shared", name: "US Shared 1GB", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 299, features: [] }, - { id: "prx_sh_us_5g", type: "shared", name: "US Shared 5GB", country: "US", countryName: "United States", carrier: "tmobile", carrierName: "T-Mobile", dataGb: 5, durationDays: 30, period: "monthly", priceCents: 1249, features: [] }, - { id: "prx_sh_gb_1g", type: "shared", name: "UK Shared 1GB", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 299, features: [] }, - { id: "prx_sh_de_1g", type: "shared", name: "DE Shared 1GB", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 329, features: [] }, - { id: "prx_sh_br_1g", type: "shared", name: "BR Shared 1GB", country: "BR", countryName: "Brazil", carrier: "claro", carrierName: "Claro", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 199, features: [] }, - { id: "prx_sh_in_1g", type: "shared", name: "IN Shared 1GB", country: "IN", countryName: "India", carrier: "jio", carrierName: "Jio", dataGb: 1, durationDays: 30, period: "monthly", priceCents: 99, features: [] }, - { id: "prx_ds_us_mo", type: "dedicated_standard", name: "US Dedicated Standard", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: null, durationDays: 30, period: "monthly", priceCents: 8000, features: ["socks5", "rotation"] }, - { id: "prx_ds_gb_mo", type: "dedicated_standard", name: "UK Dedicated Standard", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: null, durationDays: 30, period: "monthly", priceCents: 8500, features: ["socks5", "rotation"] }, - { id: "prx_ds_de_mo", type: "dedicated_standard", name: "DE Dedicated Standard", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: null, durationDays: 30, period: "monthly", priceCents: 9000, features: ["socks5", "rotation"] }, - { id: "prx_ds_nl_mo", type: "dedicated_standard", name: "NL Dedicated Standard", country: "NL", countryName: "Netherlands", carrier: "kpn", carrierName: "KPN", dataGb: null, durationDays: 30, period: "monthly", priceCents: 7500, features: ["socks5", "rotation"] }, - { id: "prx_dp_us_mo", type: "dedicated_premium", name: "US Dedicated Premium", country: "US", countryName: "United States", carrier: "verizon", carrierName: "Verizon", dataGb: null, durationDays: 30, period: "monthly", priceCents: 12000, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] }, - { id: "prx_dp_gb_mo", type: "dedicated_premium", name: "UK Dedicated Premium", country: "GB", countryName: "United Kingdom", carrier: "vodafone", carrierName: "Vodafone", dataGb: null, durationDays: 30, period: "monthly", priceCents: 13000, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] }, - { id: "prx_dp_de_mo", type: "dedicated_premium", name: "DE Dedicated Premium", country: "DE", countryName: "Germany", carrier: "telekom", carrierName: "Deutsche Telekom", dataGb: null, durationDays: 30, period: "monthly", priceCents: 13500, features: ["vless", "p0f", "socks5", "rotation", "dedicated_dns"] }, -]; - -export function searchProducts(opts?: { - country?: string; - type?: ProxyType; -}): ProxyProduct[] { - let results = proxyProducts; - if (opts?.country) { - const c = opts.country.toUpperCase(); - results = results.filter((p) => p.country === c); - } - if (opts?.type) results = results.filter((p) => p.type === opts.type); - return results; -} - -export function getProduct(productId: string): ProxyProduct | undefined { - return proxyProducts.find((p) => p.id === productId); -} diff --git a/src/mock-data/sms.ts b/src/mock-data/sms.ts deleted file mode 100644 index 1bb9315..0000000 --- a/src/mock-data/sms.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface SmsService { - id: string; - service: string; - serviceName: string; - available: boolean; - priceUsdCents: number; - ltr3PriceCents: number; - ltr7PriceCents: number; - ltr14PriceCents: number; - ltr30PriceCents: number; - dedicatedPriceCents: number; - hasIcon: boolean; -} - -export const smsServices: SmsService[] = [ - { id: "wa", service: "wa", serviceName: "WhatsApp", available: true, priceUsdCents: 250, ltr3PriceCents: 550, ltr7PriceCents: 900, ltr14PriceCents: 1500, ltr30PriceCents: 2500, dedicatedPriceCents: 3000, hasIcon: true }, - { id: "tg", service: "tg", serviceName: "Telegram", available: true, priceUsdCents: 150, ltr3PriceCents: 350, ltr7PriceCents: 600, ltr14PriceCents: 1000, ltr30PriceCents: 1700, dedicatedPriceCents: 2200, hasIcon: true }, - { id: "go", service: "go", serviceName: "Google / Gmail", available: true, priceUsdCents: 180, ltr3PriceCents: 400, ltr7PriceCents: 700, ltr14PriceCents: 1200, ltr30PriceCents: 2000, dedicatedPriceCents: 2500, hasIcon: true }, - { id: "tw", service: "tw", serviceName: "Twitter / X", available: true, priceUsdCents: 200, ltr3PriceCents: 450, ltr7PriceCents: 750, ltr14PriceCents: 1300, ltr30PriceCents: 2200, dedicatedPriceCents: 2700, hasIcon: true }, - { id: "ig", service: "ig", serviceName: "Instagram", available: true, priceUsdCents: 220, ltr3PriceCents: 500, ltr7PriceCents: 850, ltr14PriceCents: 1400, ltr30PriceCents: 2400, dedicatedPriceCents: 2900, hasIcon: true }, - { id: "ds", service: "ds", serviceName: "Discord", available: true, priceUsdCents: 120, ltr3PriceCents: 280, ltr7PriceCents: 480, ltr14PriceCents: 800, ltr30PriceCents: 1400, dedicatedPriceCents: 1800, hasIcon: true }, - { id: "tk", service: "tk", serviceName: "TikTok", available: true, priceUsdCents: 250, ltr3PriceCents: 550, ltr7PriceCents: 900, ltr14PriceCents: 1500, ltr30PriceCents: 2500, dedicatedPriceCents: 3000, hasIcon: true }, - { id: "fb", service: "fb", serviceName: "Facebook", available: true, priceUsdCents: 180, ltr3PriceCents: 400, ltr7PriceCents: 700, ltr14PriceCents: 1200, ltr30PriceCents: 2000, dedicatedPriceCents: 2500, hasIcon: true }, - { id: "ub", service: "ub", serviceName: "Uber", available: true, priceUsdCents: 200, ltr3PriceCents: 450, ltr7PriceCents: 750, ltr14PriceCents: 1300, ltr30PriceCents: 2200, dedicatedPriceCents: 2700, hasIcon: true }, - { id: "oa", service: "oa", serviceName: "OpenAI / ChatGPT", available: true, priceUsdCents: 300, ltr3PriceCents: 650, ltr7PriceCents: 1100, ltr14PriceCents: 1800, ltr30PriceCents: 3000, dedicatedPriceCents: 3500, hasIcon: true }, -]; - -export function searchServices(query?: string): SmsService[] { - if (!query) return smsServices.filter((s) => s.available); - const q = query.toLowerCase(); - return smsServices.filter( - (s) => s.available && ( - s.serviceName.toLowerCase().includes(q) || - s.id.toLowerCase().includes(q) || - s.service.toLowerCase().includes(q) - ) - ); -} - -export function getService(serviceId: string): SmsService | undefined { - return smsServices.find((s) => s.id === serviceId || s.service === serviceId); -} diff --git a/src/modes/live.ts b/src/modes/live.ts new file mode 100644 index 0000000..aecf8f6 --- /dev/null +++ b/src/modes/live.ts @@ -0,0 +1,35 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import type { Config } from "../config.js"; +import { createHttpClient } from "../client/http.js"; +import { registerAccountTools } from "../tools/account.js"; +import { registerSmsTools } from "../tools/sms.js"; +import { registerEsimTools } from "../tools/esim.js"; +import { registerProxyTools } from "../tools/proxy.js"; +import { registerGeoTools } from "../tools/geo.js"; +import { registerOrdersTools } from "../tools/orders.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) as { version: string }; + +export function buildLiveServer(cfg: Config): McpServer { + if (!cfg.apiKey) throw new Error("buildLiveServer requires an API key"); + const http = createHttpClient({ + apiKey: cfg.apiKey, + baseUrl: cfg.baseUrl, + debug: cfg.debug, + userAgent: `voidmob-mcp/${pkg.version} node/${process.version}`, + }); + const server = new McpServer({ name: "@voidmob/mcp", version: pkg.version }); + + registerAccountTools(server, http); + registerSmsTools(server, http); + registerEsimTools(server, http); + registerProxyTools(server, http); + registerGeoTools(server, http); + registerOrdersTools(server, http); + + return server; +} diff --git a/src/modes/sandbox.ts b/src/modes/sandbox.ts new file mode 100644 index 0000000..ec52bb3 --- /dev/null +++ b/src/modes/sandbox.ts @@ -0,0 +1,31 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { createSandboxHttpClient } from "../sandbox/mock-http.js"; +import { registerAccountTools } from "../tools/account.js"; +import { registerSmsTools } from "../tools/sms.js"; +import { registerEsimTools } from "../tools/esim.js"; +import { registerProxyTools } from "../tools/proxy.js"; +import { registerGeoTools } from "../tools/geo.js"; +import { registerOrdersTools } from "../tools/orders.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) as { version: string }; + +// Sandbox mode registers the exact same live tools, but injects an in-memory +// mock HttpClient instead of the real one. The tool surface is therefore +// identical to live by construction - it cannot drift. +export function buildSandboxServer(): McpServer { + const http = createSandboxHttpClient(); + const server = new McpServer({ name: "@voidmob/mcp", version: pkg.version }); + + registerAccountTools(server, http); + registerSmsTools(server, http); + registerEsimTools(server, http); + registerProxyTools(server, http); + registerGeoTools(server, http); + registerOrdersTools(server, http); + + return server; +} diff --git a/src/sandbox/mock-http.ts b/src/sandbox/mock-http.ts new file mode 100644 index 0000000..abee7b7 --- /dev/null +++ b/src/sandbox/mock-http.ts @@ -0,0 +1,560 @@ +import type { HttpClient, HttpResponse, HttpRequestOpts } from "../client/http.js"; +import type { + MePayload, + SmsService, + Verification, + Rental, + EsimProduct, + Esim, + EsimUsage, + Proxy, + ProxyList, + ProxyPlan, +} from "../client/types.js"; +import { formatUsd } from "../utils/format.js"; + +// In-memory mock of the VoidMob v1 API. The sandbox registers the SAME live +// tools (src/tools/*) but with this client injected instead of the real HTTP +// one, so the sandbox tool surface is always identical to live by construction. +// Everything here is fake data generated at runtime; nothing leaves the process +// and state resets when the server restarts. + +// ── tiny generators ────────────────────────────────────────────────────────── + +let seq = 0; +const uid = (prefix: string): string => `${prefix}${Date.now().toString(36)}${(seq++).toString(36)}`; +const rnd = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min; +const hex = (n: number): string => Array.from({ length: n }, () => rnd(0, 15).toString(16)).join(""); +const alnum = (n: number): string => { + const c = "abcdefghijkmnpqrstuvwxyz23456789"; + return Array.from({ length: n }, () => c[rnd(0, c.length - 1)]).join(""); +}; +const phone = (): string => `+1${rnd(200, 989)}${rnd(200, 989)}${rnd(1000, 9999)}`; +const smsCode = (): string => String(rnd(100000, 999999)); +const ip = (): string => `${rnd(11, 223)}.${rnd(1, 254)}.${rnd(1, 254)}.${rnd(1, 254)}`; +const iso = (offsetMs = 0): string => new Date(Date.now() + offsetMs).toISOString(); +const DAY = 86_400_000; + +// Time (ms) before a verification's code "arrives" / a proxy goes active, so the +// poll-until-ready flow the tools describe is demonstrable without a long wait. +const READY_AFTER_MS = 1_500; + +// ── catalog (static, shaped to the live Zod schemas) ───────────────────────── + +const SERVICES: SmsService[] = [ + { id: "svc_whatsapp", name: "WhatsApp", quoted_price_cents: 250, available: true, ltr_3d_price_cents: 550, ltr_7d_price_cents: 900, ltr_14d_price_cents: 1500, ltr_30d_price_cents: 2500 }, + { id: "svc_telegram", name: "Telegram", quoted_price_cents: 150, available: true, ltr_3d_price_cents: 350, ltr_7d_price_cents: 600, ltr_14d_price_cents: 1000, ltr_30d_price_cents: 1700 }, + { id: "svc_google", name: "Google", quoted_price_cents: 180, available: true, ltr_3d_price_cents: 400, ltr_7d_price_cents: 700, ltr_14d_price_cents: 1200, ltr_30d_price_cents: 2000 }, + { id: "svc_twitter", name: "Twitter / X", quoted_price_cents: 200, available: true, ltr_7d_price_cents: 750, ltr_30d_price_cents: 2200 }, + { id: "svc_instagram", name: "Instagram", quoted_price_cents: 220, available: true, ltr_7d_price_cents: 850, ltr_30d_price_cents: 2400 }, + { id: "svc_discord", name: "Discord", quoted_price_cents: 120, available: true, ltr_7d_price_cents: 480, ltr_30d_price_cents: 1400 }, + { id: "svc_tiktok", name: "TikTok", quoted_price_cents: 250, available: true, ltr_7d_price_cents: 900 }, + { id: "svc_openai", name: "OpenAI", quoted_price_cents: 300, available: true, ltr_7d_price_cents: 1100, ltr_30d_price_cents: 3000 }, + { id: "svc_dedicated_28d", name: "Dedicated (all services)", quoted_price_cents: 3500, available: true, ltr_28d_price_cents: 3500 }, +]; + +const esimFeatures = (over: Partial = {}): EsimProduct["features"] => ({ + has_5g: true, + has_hotspot: true, + has_calls: false, + has_sms: false, + supports_topup: true, + ...over, +}); + +const ESIM_PRODUCTS: EsimProduct[] = [ + { id: "prod_us_5gb_30d", title: "USA 5GB 30 days", countries: ["US"], region: null, country_count: 1, routing_location: "US", data_limit_gb: 5, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 1500, currency: "USD" }, + { id: "prod_eu_10gb_30d", title: "Europe 10GB 30 days", countries: ["FR", "DE", "ES", "IT", "NL", "PT", "BE", "AT", "IE", "SE"], region: "Europe", country_count: 10, routing_location: "DE", data_limit_gb: 10, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 2600, currency: "USD" }, + { id: "prod_jp_3gb_15d", title: "Japan 3GB 15 days", countries: ["JP"], region: null, country_count: 1, routing_location: "JP", data_limit_gb: 3, data_unlimited: false, validity_days: 15, features: esimFeatures({ has_hotspot: false }), price_cents: 1100, currency: "USD" }, + { id: "prod_global_unl_7d", title: "Global Unlimited 7 days", countries: ["US", "GB", "FR", "DE", "JP", "AU", "BR", "ZA"], region: "Global", country_count: 8, routing_location: null, data_limit_gb: null, data_unlimited: true, validity_days: 7, features: esimFeatures(), price_cents: 3200, currency: "USD" }, + { id: "prod_topup_5gb", title: "Top-up 5GB", countries: [], region: null, country_count: 0, routing_location: null, data_limit_gb: 5, data_unlimited: false, validity_days: 30, features: esimFeatures(), price_cents: 1400, currency: "USD" }, +]; + +const PROXY_PLANS: ProxyPlan[] = [ + { id: "pplan_us_5gb_30d", name: "US Mobile 5GB", type: "shared", country: "US", country_name: "United States", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 1800, available: true }, + { id: "pplan_us_10gb_30d", name: "US Mobile 10GB", type: "shared", country: "US", country_name: "United States", data_gb: 10, duration_days: 30, period: "monthly", quoted_price_cents: 3000, available: true }, + { id: "pplan_gb_5gb_30d", name: "UK Mobile 5GB", type: "shared", country: "GB", country_name: "United Kingdom", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 2000, available: true }, + { id: "pplan_de_5gb_30d", name: "Germany Mobile 5GB", type: "shared", country: "DE", country_name: "Germany", data_gb: 5, duration_days: 30, period: "monthly", quoted_price_cents: 2100, available: true }, +]; + +const GEO: Record = { + countries: [ + { code: "US", name: "United States", available_nodes: 4200 }, + { code: "GB", name: "United Kingdom", available_nodes: 1800 }, + { code: "DE", name: "Germany", available_nodes: 1500 }, + ], + regions: [ + { name: "California", available_nodes: 920 }, + { name: "New York", available_nodes: 740 }, + { name: "Texas", available_nodes: 610 }, + ], + cities: [ + { name: "Los Angeles", available_nodes: 410 }, + { name: "San Francisco", available_nodes: 300 }, + ], + isps: [ + { name: "Carrier A", available_nodes: 210 }, + { name: "Carrier B", available_nodes: 160 }, + ], +}; + +// ── state ──────────────────────────────────────────────────────────────────── + +class Store { + balanceCents = 50000; // $500 play-money balance (sandbox has no deposit tool, so spend-only) + verifications = new Map(); + rentals = new Map(); + esims = new Map(); + proxies = new Map(); + createdAtMs = new Map(); // entity id -> creation epoch ms +} + +// ── response envelope helpers ───────────────────────────────────────────────── + +const ok = (data: T, status = 200): HttpResponse => ({ status, body: { success: true, data }, headers: new Headers() }); +const noContent = (): HttpResponse => ({ status: 204, headers: new Headers() }); +const fail = (status: number, code: string, message: string, details?: Record): HttpResponse => ({ + status, + body: { success: false, error: { code, message, request_id: uid("req_"), details } }, + headers: new Headers(), +}); + +const PNG_1x1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", +); + +// ── entity builders ──────────────────────────────────────────────────────────── + +function makeGateway(geoHint = "us"): NonNullable { + return { + host: `${geoHint}.gw.voidmob.com`, + port: 10000 + rnd(0, 4999), + protocol: "http", + username: `vm_${alnum(6)}`, + password: alnum(12), + username_geo_hint: geoHint, + }; +} + +export function createSandboxHttpClient(): HttpClient { + const db = new Store(); + + // True once READY_AFTER_MS has elapsed since the entity was (re)armed. + const isReady = (id: string): boolean => Date.now() - (db.createdAtMs.get(id) ?? 0) >= READY_AFTER_MS; + + // Charge the wallet, or return a 402 response if the balance can't cover it. + const charge = (cents: number): HttpResponse | null => + db.balanceCents < cents ? fail(402, "INSUFFICIENT_BALANCE", "Insufficient balance.") : ((db.balanceCents -= cents), null); + + // Lazily flip a just-created verification to code_received once the code has + // "arrived", mirroring the real "poll get_rental until the code lands" flow. + const settleVerification = (v: Verification): Verification => { + if (v.status !== "waiting_for_code" || !isReady(v.id)) return v; + v.status = "code_received"; + v.code = smsCode(); + v.code_received_at = iso(); + v.can_cancel = false; + v.allow_reuse = true; + return v; + }; + + // Proxies provision asynchronously; flip to active once ready. + const settleProxy = (p: Proxy): Proxy => { + if (p.status === "provisioning" && isReady(p.id)) p.status = "active"; + return p; + }; + + function route(method: string, rawPath: string, query: URLSearchParams, opts: HttpRequestOpts): HttpResponse { + const body = (opts.body ?? {}) as Record; + const seg = rawPath.split("/").filter(Boolean); // e.g. ["v1","proxies","prx_1","lists"] + + // ── account ── + if (method === "GET" && rawPath === "/v1/me") { + const me: MePayload = { + id: "acct_sandbox", + balance: { amount_cents: db.balanceCents, currency: "USD", formatted: formatUsd(db.balanceCents) }, + rate_limits: { + default: { limit: 120, window_seconds: 60 }, + purchases: { limit: 30, window_seconds: 60 }, + }, + created_at: iso(-90 * DAY), + }; + return ok(me); + } + + // ── SMS services ── + if (method === "GET" && rawPath === "/v1/services") { + return ok({ services: SERVICES }); + } + + // ── verifications ── + if (method === "POST" && rawPath === "/v1/verifications") { + const svc = SERVICES.find((s) => s.id === body.service_id); + if (!svc) return fail(404, "NOT_FOUND", "Service not found."); + const paid = charge(svc.quoted_price_cents); + if (paid) return paid; + const id = uid("ver_"); + const v: Verification = { + id, + display_id: id.slice(0, 12), + status: "waiting_for_code", + phone_number: phone(), + service_id: svc.id, + service_name: svc.name, + charged_price_cents: svc.quoted_price_cents, + expires_at: iso(20 * 60_000), + can_cancel: true, + created_at: iso(), + reuse_counter: 0, + allow_reuse: false, + allow_paid_reuse: true, + paid_reuse_price_cents: 50, + }; + db.verifications.set(id, v); + db.createdAtMs.set(id, Date.now()); + return ok({ verification: v }, 201); + } + if (seg[1] === "verifications" && seg[2]) { + const v = db.verifications.get(seg[2]); + if (!v) return fail(404, "NOT_FOUND", "Verification not found."); + if (method === "GET" && !seg[3]) return ok({ verification: settleVerification(v) }); + if (method === "POST" && seg[3] === "cancel") { + // Refund only if the code hasn't landed yet. settleVerification reflects + // elapsed time, so eligibility doesn't depend on whether the client polled. + const refund = settleVerification(v).status === "waiting_for_code" ? v.charged_price_cents : 0; + if (refund > 0) db.balanceCents += refund; + v.status = "cancelled"; + return ok({ verification: { id: v.id, status: v.status, refunded_cents: refund } }); + } + // paid reuse (/reuse/paid) is more specific than free reuse (/reuse) - match it first + if (method === "POST" && seg[3] === "reuse" && seg[4] === "paid") { + const paid = charge(v.paid_reuse_price_cents); + if (paid) return paid; + v.reuse_counter += 1; + v.charged_reuse_cents = v.paid_reuse_price_cents; + v.status = "waiting_for_code"; + v.code = undefined; + v.code_received_at = undefined; + db.createdAtMs.set(v.id, Date.now()); + return ok({ verification: v }); + } + if (method === "POST" && seg[3] === "reuse") { + v.reuse_counter += 1; + v.status = "waiting_for_code"; + v.code = undefined; + v.code_received_at = undefined; + db.createdAtMs.set(v.id, Date.now()); + return ok({ verification: v }); + } + } + + // ── rentals (long-term + dedicated) ── + if (rawPath === "/v1/rentals" && method === "GET") { + return ok([...db.rentals.values()]); + } + if (rawPath === "/v1/rentals" && method === "POST") { + const svc = SERVICES.find((s) => s.id === body.service_id); + if (!svc) return fail(404, "NOT_FOUND", "Service not found."); + const price = Number(body.max_price_cents ?? svc.quoted_price_cents); + const paid = charge(price); + if (paid) return paid; + const id = uid("ren_"); + const duration = String(body.duration ?? "7D"); + const days = parseInt(duration, 10) || 7; + const r: Rental = { + id, + display_id: id.slice(0, 12), + status: "active", + phone_number: phone(), + service_id: svc.id, + service_name: svc.name, + country: "US", + duration, + rental_type: "rental", + charged_price_cents: price, + auto_renew: false, + next_renewal_price_cents: price, + re_rent_available: false, + re_rent_price_cents: null, + re_rent_blocked_at: null, + created_at: iso(), + paid_until: iso(days * DAY), + expires_at: iso(days * DAY), + can_cancel: true, + cancel_window_expires_at: iso(30_000), + messages: [], + }; + db.rentals.set(id, r); + db.createdAtMs.set(id, Date.now()); + return ok(r, 201); + } + if (seg[1] === "rentals" && seg[2]) { + const r = db.rentals.get(seg[2]); + if (!r) return fail(404, "NOT_FOUND", "Rental not found."); + if (method === "GET" && !seg[3]) return ok(r); + if (method === "DELETE") { + r.status = "cancelled"; + return ok(r); + } + if (method === "POST" && seg[3] === "re_rent") { + const paid = charge(r.charged_price_cents); + if (paid) return paid; + const days = parseInt(r.duration, 10) || 7; + r.status = "active"; + r.paid_until = iso(days * DAY); + r.expires_at = iso(days * DAY); + return ok(r); + } + if (method === "POST" && seg[3] === "auto_renew") { + r.auto_renew = Boolean(body.auto_renew); + return ok(r); + } + } + + // ── eSIM products ── + if (rawPath === "/v1/esim_products" && method === "GET") { + let products = ESIM_PRODUCTS.filter((p) => !p.id.startsWith("prod_topup")); + const country = query.get("country"); + const minGb = query.get("min_data_gb"); + const minDays = query.get("min_validity_days"); + const has5g = query.get("has_5g"); + const search = query.get("search"); + if (country) products = products.filter((p) => p.countries.includes(country.toUpperCase())); + if (minGb) products = products.filter((p) => p.data_unlimited || (p.data_limit_gb ?? 0) >= Number(minGb)); + if (minDays) products = products.filter((p) => p.validity_days >= Number(minDays)); + if (has5g) products = products.filter((p) => p.features.has_5g === (has5g === "true")); + if (search) products = products.filter((p) => p.title.toLowerCase().includes(search.toLowerCase())); + return ok({ products, next_cursor: null }); + } + if (seg[1] === "esim_products" && seg[2] && method === "GET") { + const product = ESIM_PRODUCTS.find((p) => p.id === seg[2]); + if (!product) return fail(404, "NOT_FOUND", "Product not found."); + return ok({ product }); + } + + // ── eSIMs ── + if (rawPath === "/v1/esims" && method === "GET") { + return ok({ esims: [...db.esims.values()] }); + } + if (rawPath === "/v1/esims" && method === "POST") { + const product = ESIM_PRODUCTS.find((p) => p.id === body.product_id); + if (!product) return fail(404, "NOT_FOUND", "Product not found."); + const paid = charge(product.price_cents); + if (paid) return paid; + const id = uid("esim_"); + const esim: Esim = { + id, + status: "completed", + product_id: product.id, + is_topup: false, + parent_order_id: null, + iccid: `8910${rnd(10, 99)}${hex(14)}`, + activation_code: `LPA:1$smdp.voidmob.com$${hex(32).toUpperCase()}`, + qr_code_url: `/v1/esims/${id}/qr.png`, + smdp_address: "smdp.voidmob.com", + data_limit_gb: product.data_limit_gb, + data_unlimited: product.data_unlimited, + validity_days: product.validity_days, + countries: product.countries, + routing_location: product.routing_location, + charged_price_cents: product.price_cents, + currency: "USD", + created_at: iso(), + completed_at: iso(), + expires_at: iso(product.validity_days * DAY), + }; + db.esims.set(id, esim); + return ok({ esim }, 201); + } + if (seg[1] === "esims" && seg[2]) { + const esim = db.esims.get(seg[2]); + if (seg[3] === "qr.png" && method === "GET") { + if (!esim) return fail(404, "NOT_FOUND", "eSIM not found."); + return { status: 200, binary: PNG_1x1, headers: new Headers({ "content-type": "image/png" }) }; + } + if (!esim) return fail(404, "NOT_FOUND", "eSIM not found."); + if (method === "GET" && !seg[3]) return ok({ esim }); + if (seg[3] === "usage" && method === "GET") { + const totalGb = esim.data_unlimited ? 50 : esim.data_limit_gb ?? 0; + const totalMb = totalGb * 1024; + const usedMb = Math.min(totalMb, rnd(0, Math.floor(totalMb * 0.6))); + const usage: EsimUsage = { + esim_id: esim.id, + esim_status: esim.status, + packages: [ + { + name: "Primary", + total_mb: totalMb, + total_gb: totalGb, + used_mb: usedMb, + used_gb: Number((usedMb / 1024).toFixed(2)), + remaining_mb: totalMb - usedMb, + remaining_gb: Number(((totalMb - usedMb) / 1024).toFixed(2)), + percent_used: totalMb ? Math.round((usedMb / totalMb) * 100) : 0, + activation_date: esim.completed_at, + expiration_date: esim.expires_at, + }, + ], + }; + return ok({ usage }); + } + if (seg[3] === "topups" && method === "GET") { + const topups = ESIM_PRODUCTS.filter((p) => p.id.startsWith("prod_topup")); + return ok({ supports_topup: true, topups }); + } + if (seg[3] === "topups" && method === "POST") { + const product = ESIM_PRODUCTS.find((p) => p.id === body.product_id); + if (!product) return fail(404, "NOT_FOUND", "Top-up product not found."); + const paid = charge(product.price_cents); + if (paid) return paid; + const id = uid("esim_"); + const topup: Esim = { + id, + status: "completed", + product_id: product.id, + is_topup: true, + parent_order_id: esim.id, + iccid: esim.iccid, + activation_code: esim.activation_code, + qr_code_url: esim.qr_code_url, + smdp_address: esim.smdp_address, + data_limit_gb: product.data_limit_gb, + data_unlimited: product.data_unlimited, + validity_days: product.validity_days, + countries: esim.countries, + routing_location: esim.routing_location, + charged_price_cents: product.price_cents, + currency: "USD", + created_at: iso(), + completed_at: iso(), + expires_at: esim.expires_at, + }; + db.esims.set(id, topup); + return ok({ esim: topup }, 201); + } + } + + // ── proxy plans ── + if (rawPath === "/v1/proxy_plans" && method === "GET") { + let plans = PROXY_PLANS; + const country = query.get("country"); + const minGb = query.get("min_gb"); + if (country) plans = plans.filter((p) => p.country === country.toUpperCase()); + if (minGb) plans = plans.filter((p) => p.data_gb >= Number(minGb)); + return ok({ plans }); + } + + // ── proxies ── + if (rawPath === "/v1/proxies" && method === "GET") { + return ok({ proxies: [...db.proxies.values()].map(settleProxy) }); + } + if (rawPath === "/v1/proxies" && method === "POST") { + const plan = PROXY_PLANS.find((p) => p.id === body.plan_id); + if (!plan) return fail(404, "NOT_FOUND", "Plan not found."); + const paid = charge(plan.quoted_price_cents); + if (paid) return paid; + const id = uid("prx_"); + const proxy: Proxy = { + id, + status: "provisioning", + plan_id: plan.id, + data_gb_total: plan.data_gb, + data_bytes_used: 0, + charged_price_cents: plan.quoted_price_cents, + expires_at: iso(plan.duration_days * DAY), + gateway: null, + lists: [], + rotation_url: null, + created_at: iso(), + }; + db.proxies.set(id, proxy); + db.createdAtMs.set(id, Date.now()); + return ok({ proxy }, 202); + } + if (seg[1] === "proxies" && seg[2]) { + const proxy = db.proxies.get(seg[2]); + if (!proxy) return fail(404, "NOT_FOUND", "Proxy not found."); + const geoHint = (PROXY_PLANS.find((p) => p.id === proxy.plan_id)?.country ?? "us").toLowerCase(); + + if (method === "GET" && !seg[3]) return ok({ proxy: settleProxy(proxy) }); + if (seg[3] === "usage" && method === "GET") { + return ok({ usage: { total_gb: proxy.data_gb_total, used_gb: Number((proxy.data_bytes_used / 1024 ** 3).toFixed(2)) } }); + } + if (seg[3] === "nolist_credentials" && method === "POST") { + settleProxy(proxy); + if (proxy.status === "active" && !proxy.gateway) proxy.gateway = makeGateway(geoHint); + return ok({ proxy }); + } + if (seg[3] === "rotate_ip" && method === "POST") { + return ok({ proxy_id: proxy.id, rotated_at: iso(), current_ip: ip() }); + } + if (seg[3] === "renew" && method === "POST") { + const paid = charge(Number(body.max_price_cents ?? proxy.charged_price_cents)); + if (paid) return paid; + const days = PROXY_PLANS.find((p) => p.id === proxy.plan_id)?.duration_days ?? 30; + proxy.expires_at = iso(days * DAY); + return ok({ proxy }); + } + if (seg[3] === "topup" && method === "POST") { + const paid = charge(Number(body.max_price_cents ?? 0)); + if (paid) return paid; + proxy.data_gb_total += Number(body.additional_gb ?? 0); + return ok({ proxy }); + } + if (seg[3] === "regenerate_password" && method === "POST") { + proxy.gateway = makeGateway(geoHint); + return ok({ proxy }); + } + if (seg[3] === "lists" && !seg[4] && method === "POST") { + const id = uid("plist_"); + const single = typeof body.country === "string" ? body.country : null; + const gw = makeGateway(geoHint); + const list: ProxyList = { + id, + proxy_id: proxy.id, + name: String(body.name ?? "list"), + country: single, + countries: Array.isArray(body.countries) ? (body.countries as string[]) : null, + region: (body.region as string) ?? null, + city: (body.city as string) ?? null, + isp: (body.isp as string) ?? null, + zip: (body.zip as string) ?? null, + rotation_period_seconds: Number(body.rotation_period_seconds ?? 0), + rotation_mode: String(body.rotation_mode ?? "instant"), + format: String(body.format ?? "login_pass_host_port"), + credentials: { host: gw.host, port: gw.port, protocol: gw.protocol, username: gw.username, password: gw.password }, + entries: [`${gw.host}:${gw.port}:${gw.username}:${gw.password}`], + activation_note: "Active within 1-2 minutes.", + created_at: iso(), + }; + proxy.lists.push(list); + return ok({ list }, 201); + } + if (seg[3] === "lists" && seg[4] && method === "DELETE") { + proxy.lists = proxy.lists.filter((l) => l.id !== seg[4]); + return noContent(); + } + } + + // ── geo (cascading) ── + if (rawPath === "/v1/geo" && method === "GET") { + const country = query.get("country"); + const region = query.get("region"); + const city = query.get("city"); + if (city) return ok({ isps: GEO.isps }); + if (region) return ok({ cities: GEO.cities }); + if (country) return ok({ regions: GEO.regions }); + return ok({ countries: GEO.countries }); + } + + return fail(404, "NOT_FOUND", `No sandbox route for ${method} ${rawPath}.`); + } + + return { + async request(method, path, opts = {}): Promise { + const [rawPath, queryStr = ""] = path.split("?"); + return route(method.toUpperCase(), rawPath, new URLSearchParams(queryStr), opts); + }, + }; +} diff --git a/src/sandbox/state.ts b/src/sandbox/state.ts deleted file mode 100644 index eda9a50..0000000 --- a/src/sandbox/state.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { generateId } from "../utils/validation.js"; - -// ── SMS ── - -export interface SmsMessage { - id: string; - message_text: string; - code: string | null; - received_at: number; - read_at: number | null; -} - -export interface SmsRental { - id: string; - displayId: string; - phoneNumber: string; - service: string; - serviceName: string; - rentalType: "verification" | "rental" | "dedicated"; - duration: string | null; - autoRenew: boolean; - paidUntil: number | null; - status: "active" | "completed" | "cancelled" | "expired"; - messages: SmsMessage[]; - expiresAt: number; - createdAt: number; - priceCents: number; - reuseCounter: number; -} - -// ── eSIM ── - -export interface EsimOrder { - id: string; - displayId: string; - planId: string; - planTitle: string; - countries: string[]; - dataLimitGb: number | null; - dataUnlimited: boolean; - validityDays: number; - dataUsedMb: number; - status: "active" | "completed" | "expired"; - retailPriceUsd: number; - qrCodeData: string; - activationCode: string; - iccid: string; - isTopup: boolean; - parentOrderId: string | null; - supportsTopup: boolean; - expiresAt: number; - createdAt: number; -} - -// ── Proxy ── - -export type ProxyType = "shared" | "dedicated_standard" | "dedicated_premium"; - -export interface ProxyEntry { - id: string; - displayId: string; - type: ProxyType; - status: "active" | "expired"; - proxyHost: string; - proxyPort: number; - socksPort: number | null; - proxyUsername: string; - proxyPassword: string; - protocol: "http" | "socks5" | "vless"; - country: string; - countryName: string; - carrier: string; - carrierName: string; - currentIp: string; - isOnline: boolean; - dataTotal: number | null; - dataUsed: number | null; - rotationInterval: number | null; - lastRotatedAt: number | null; - autoRenew: boolean; - expiresAt: number; - createdAt: number; - priceCents: number; - features: string[]; - lists: ProxyList[]; -} - -export interface ProxyList { - id: string; - name: string; - login: string; - password: string; - country: string | null; - region: string | null; - city: string | null; - isp: string | null; - locationPreset: string; - countries: string[] | null; - rotationPeriod: number; -} - -// ── Wallet ── - -export interface Deposit { - invoiceId: string; - amount: number; - currency: string; - walletAddress: string; - qrCodeUrl: string; - cryptoAmount: string; - status: "pending" | "completed"; - expiresAt: number; - createdAt: number; -} - -export type TransactionType = - | "deposit" - | "sms_verification" - | "sms_rental" - | "sms_dedicated" - | "sms_reuse" - | "esim_purchase" - | "esim_topup" - | "proxy_purchase" - | "refund"; - -export interface Transaction { - id: string; - type: TransactionType; - amountCents: number; - description: string; - createdAt: number; -} - -// ── State ── - -class SandboxState { - balanceCents = 5000; - transactions: Transaction[] = []; - smsRentals = new Map(); - esimOrders = new Map(); - proxies = new Map(); - deposits = new Map(); - - deductBalance(cents: number, type: TransactionType, description: string): boolean { - if (this.balanceCents < cents) return false; - this.balanceCents -= cents; - this.transactions.push({ - id: generateId("tx"), - type, - amountCents: -cents, - description, - createdAt: Date.now(), - }); - return true; - } - - addBalance(cents: number, type: TransactionType, description: string): void { - this.balanceCents += cents; - this.transactions.push({ - id: generateId("tx"), - type, - amountCents: cents, - description, - createdAt: Date.now(), - }); - } - - resolvePendingDeposits(): void { - const now = Date.now(); - for (const deposit of this.deposits.values()) { - if (deposit.status === "pending" && now - deposit.createdAt > 5000) { - deposit.status = "completed"; - const cents = Math.round(deposit.amount * 100); - this.addBalance(cents, "deposit", `Crypto deposit (${deposit.currency})`); - } - } - } -} - -export const state = new SandboxState(); diff --git a/src/tools/account.ts b/src/tools/account.ts new file mode 100644 index 0000000..647b436 --- /dev/null +++ b/src/tools/account.ts @@ -0,0 +1,35 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { HttpClient } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; +import { MePayload } from "../client/types.js"; +import { structuredOk, wrapToolErrors, type ToolResult } from "../utils/render.js"; + +// Exported as a factory for test direct-invocation. registerAccountTools wires +// it onto the McpServer; tests can call getAccountHandler(mockHttp)() without +// going through the SDK private internals. +export const getAccountHandler = (http: HttpClient) => + wrapToolErrors(async (): Promise => { + const raw = await callApi(http, "GET", "/v1/me"); + const me = MePayload.parse(raw); + const text = [ + `Account ${me.id}`, + ``, + ` Balance: ${me.balance.formatted}`, + ` Created: ${me.created_at.slice(0, 10)}`, + ``, + ` Rate limits (per 60s):`, + ...Object.entries(me.rate_limits).map( + ([g, l]) => ` ${g.padEnd(20)} ${l.limit}/min`, + ), + ].join("\n"); + return structuredOk(text, { account: me }); + }); + +export function registerAccountTools(server: McpServer, http: HttpClient) { + server.tool( + "get_account", + "Get the authenticated account: id, USD wallet balance, and per-endpoint-group rate limits. Use this before money-touching tool calls to confirm sufficient funds.", + {}, + getAccountHandler(http), + ); +} diff --git a/src/tools/esim.ts b/src/tools/esim.ts index 93a85fd..877858a 100644 --- a/src/tools/esim.ts +++ b/src/tools/esim.ts @@ -1,396 +1,242 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { state, EsimOrder } from "../sandbox/state.js"; -import { ToolError, generateId } from "../utils/validation.js"; -import { - formatUsd, - formatMb, - formatData, - formatTimeRemaining, - generateDisplayId, - generateIccid, - generateActivationCode, -} from "../utils/format.js"; -import { errorResponse, textResponse } from "../utils/response.js"; -import { - searchPlans, - getPlan, - getTopupProducts, - getTopupProduct, -} from "../mock-data/esim.js"; - -function featureBadges(has5g: boolean, hasHotspot: boolean): string { - const badges: string[] = []; - if (has5g) badges.push("5G"); - if (hasHotspot) badges.push("Hotspot"); - return badges.length > 0 ? badges.join(", ") : "None"; -} - -export function registerEsimTools(server: McpServer) { - server.tool( - "search_esim_plans", - "Search available eSIM data plans by country, data, duration, or features.", - { - country: z - .string() - .optional() - .describe("ISO 3166-1 alpha-2 country code (e.g., JP, US, GB)"), - duration: z - .number() - .min(1) - .optional() - .describe("Minimum plan validity in days"), - dataAmount: z - .number() - .min(1) - .optional() - .describe("Minimum data amount in GB"), - has5g: z.boolean().optional().describe("Filter for 5G-capable plans"), - hasHotspot: z - .boolean() - .optional() - .describe("Filter for plans with hotspot/tethering"), - search: z - .string() - .optional() - .describe("Text search in plan title (e.g., 'Japan', 'Europe')"), - limit: z - .number() - .min(1) - .max(50) - .default(20) - .describe("Maximum results to return (default: 20)"), - }, - async ({ country, duration, dataAmount, has5g, hasHotspot, search, limit }) => { - try { - const results = searchPlans({ - country, - duration, - dataAmount, - has5g, - hasHotspot, - search, - limit, - }); - - if (results.length === 0) { - return errorResponse( - "No eSIM plans found matching your criteria. Try different filters or omit them to browse all plans." - ); - } - - let text = `Found ${results.length} eSIM plan(s):\n\n`; - - for (const plan of results) { - text += ` ${plan.title} (${plan.id})\n`; - text += ` Countries: ${plan.countries.join(", ")}\n`; - text += ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}\n`; - text += ` Duration: ${plan.validityDays} days\n`; - text += ` Price: $${plan.retailPriceUsd.toFixed(2)}\n`; - text += ` Features: ${featureBadges(plan.has5g, plan.hasHotspot)}\n`; - text += ` Routing: ${plan.routingLocation}\n`; - text += ` Top-up: ${plan.supportsTopup ? "Yes" : "No"}\n\n`; - } - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } +import { HttpClient, HttpError, NetworkError } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; +import { newIdempotencyKey } from "../client/idempotency.js"; +import { EsimProduct, Esim, EsimUsage } from "../client/types.js"; +import { structuredOk, structuredWithImage, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js"; +import { formatUsd, formatData } from "../utils/format.js"; + +// ── search_esim_plans ─────────────────────────────────────────────────────── + +export const searchEsimPlansHandler = (http: HttpClient) => + wrapToolErrors(async (args: { + country?: string; + min_data_gb?: number; + min_days?: number; + has_5g?: boolean; + has_hotspot?: boolean; + query?: string; + limit?: number; + cursor?: string; + }): Promise => { + const q = new URLSearchParams(); + if (args.country) q.set("country", args.country); + if (args.min_data_gb !== undefined) q.set("min_data_gb", String(args.min_data_gb)); + // API param is min_validity_days (not min_days); has_5g is server-side. + if (args.min_days !== undefined) q.set("min_validity_days", String(args.min_days)); + if (args.has_5g !== undefined) q.set("has_5g", String(args.has_5g)); + if (args.query) q.set("search", args.query); + q.set("limit", String(args.limit ?? 20)); + if (args.cursor) q.set("cursor", args.cursor); + + const path = `/v1/esim_products?${q.toString()}`; + const data = await callApi<{ products: unknown[]; next_cursor: string | null }>( + http, + "GET", + path, + ); + let products = z.array(EsimProduct).parse(data.products); + // has_hotspot is not a server-side filter, so apply it to the returned page + // (rather than silently ignoring it as the query param did before). + if (args.has_hotspot !== undefined) { + products = products.filter((p) => p.features.has_hotspot === args.has_hotspot); } - ); - - server.tool( - "get_esim_plan_details", - "Get full details for a specific eSIM plan.", - { - planId: z.string().describe("Plan ID (e.g., 'esim_jp_3g_7d')"), - }, - async ({ planId }) => { - const plan = getPlan(planId); - - if (!plan) { - return errorResponse( - `Plan not found: ${planId}. Use search_esim_plans to browse available plans.` - ); + if (products.length === 0) return toolError("No eSIM plans matched your filters."); + const text = [ + `Found ${products.length} eSIM plan(s)${data.next_cursor ? " (more available - pass cursor to paginate)" : ""}:`, + ``, + ...products.map((p) => + [ + ` ${p.title} (${p.id})`, + ` Countries: ${p.countries.join(", ")}`, + ` Data: ${formatData(p.data_limit_gb, p.data_unlimited)}`, + ` Validity: ${p.validity_days} days`, + ` Price: ${formatUsd(p.price_cents)}`, + ` 5G/Hotspot: ${p.features.has_5g ? "yes" : "no"} / ${p.features.has_hotspot ? "yes" : "no"}`, + ].join("\n"), + ), + ].join("\n\n"); + return structuredOk(text, { esim_plans: products, next_cursor: data.next_cursor }); + }); + +// ── purchase_esim ─────────────────────────────────────────────────────────── + +export const purchaseEsimHandler = (http: HttpClient) => + wrapToolErrors(async (args: { plan_id: string }): Promise => { + const productResp = await callApi<{ product: unknown }>(http, "GET", `/v1/esim_products/${args.plan_id}`); + const product = EsimProduct.parse(productResp.product); + const out = await callApi<{ esim: unknown }>(http, "POST", "/v1/esims", { + body: { product_id: args.plan_id, max_price_cents: product.price_cents }, + idempotencyKey: newIdempotencyKey(), + }); + const esim = Esim.parse(out.esim); + const text = [ + `eSIM purchased: ${esim.id}`, + ``, + ` Title: ${product.title}`, + ` Countries: ${esim.countries.join(", ")}`, + ` Data: ${formatData(esim.data_limit_gb, esim.data_unlimited)}`, + ` Validity: ${esim.validity_days} days`, + ` Charged: ${formatUsd(esim.charged_price_cents)}`, + ` Activation: ${esim.activation_code ?? "(pending)"}`, + ` ICCID: ${esim.iccid ?? "(pending)"}`, + ``, + `Use get_esim_qr(esim_id="${esim.id}") to fetch the QR code as an image.`, + ].join("\n"); + return structuredOk(text, { esim }); + }); + +// ── get_esim_status ───────────────────────────────────────────────────────── + +export const getEsimStatusHandler = (http: HttpClient) => + wrapToolErrors(async (args: { esim_id: string }): Promise => { + // Usage is best-effort enrichment on a read path: degrade to null on ANY + // API/network error so a transient usage-subservice failure never sinks the + // core eSIM status read. Non-API throws (bugs) still propagate. + const [esimRaw, usageRaw] = await Promise.all([ + callApi<{ esim: unknown }>(http, "GET", `/v1/esims/${args.esim_id}`), + callApi<{ usage: unknown }>(http, "GET", `/v1/esims/${args.esim_id}/usage`).catch((e) => { + if (e instanceof HttpError || e instanceof NetworkError) return null; + throw e; + }), + ]); + const esim = Esim.parse(esimRaw.esim); + const usage = usageRaw ? EsimUsage.parse(usageRaw.usage) : null; + const primaryPkg = usage?.packages[0] ?? null; + const text = [ + `eSIM ${esim.id}`, + ``, + ` Countries: ${esim.countries.join(", ")}`, + ` Data: ${formatData(esim.data_limit_gb, esim.data_unlimited)}`, + ` Status: ${esim.status}`, + ` Validity: ${esim.validity_days} days`, + ` Expires: ${esim.expires_at ?? "(not yet activated)"}`, + primaryPkg + ? ` Usage: ${primaryPkg.used_mb.toFixed(0)} MB / ${primaryPkg.total_mb.toFixed(0)} MB (${primaryPkg.percent_used}%)` + : ` Usage: (not yet available)`, + ].join("\n"); + return structuredOk(text, { esim, usage }); + }); + +// ── topup_esim ────────────────────────────────────────────────────────────── + +export const topupEsimHandler = (http: HttpClient) => + wrapToolErrors(async (args: { esim_id: string; topup_product_id?: string }): Promise => { + if (!args.topup_product_id) { + // Browse path + const out = await callApi<{ supports_topup: boolean; topups: unknown[] }>( + http, + "GET", + `/v1/esims/${args.esim_id}/topups`, + ); + if (!out.supports_topup || out.topups.length === 0) { + return toolError(`No top-up products available for ${args.esim_id}.`); } - + const topups = z.array(EsimProduct).parse(out.topups); const text = [ - `${plan.title}`, + `Available top-ups for ${args.esim_id}:`, ``, - ` Plan ID: ${plan.id}`, - ` Countries: ${plan.countries.join(", ")}`, - ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}`, - ` Duration: ${plan.validityDays} days`, - ` Price: $${plan.retailPriceUsd.toFixed(2)}`, - ` Network: ${plan.networkType}`, - ` Speed: ${plan.speed}`, - ` 5G: ${plan.has5g ? "Yes" : "No"}`, - ` Hotspot: ${plan.hasHotspot ? "Yes" : "No"}`, - ` Activation: ${plan.activationPolicy}`, - ` Routing: ${plan.routingLocation}`, - ` Supports top-up: ${plan.supportsTopup ? "Yes" : "No"}`, - ` Tags: ${plan.tags.length > 0 ? plan.tags.join(", ") : "None"}`, + ...topups.map( + (t) => + ` ${t.title} (${t.id}) - ${formatData(t.data_limit_gb, t.data_unlimited)}, ${t.validity_days} days, ${formatUsd(t.price_cents)}`, + ), + ``, + `Re-run topup_esim with topup_product_id to purchase.`, ].join("\n"); - - return textResponse(text); + return structuredOk(text, { topups }); } - ); - + // Purchase path: quote-then-commit + const productResp = await callApi<{ product: unknown }>( + http, + "GET", + `/v1/esim_products/${args.topup_product_id}`, + ); + const product = EsimProduct.parse(productResp.product); + const created = await callApi<{ esim: unknown }>( + http, + "POST", + `/v1/esims/${args.esim_id}/topups`, + { + body: { product_id: args.topup_product_id, max_price_cents: product.price_cents }, + idempotencyKey: newIdempotencyKey(), + }, + ); + const esim = Esim.parse(created.esim); + const text = [ + `Top-up ${esim.id} purchased on ${args.esim_id}.`, + ``, + ` Title: ${product.title}`, + ` Data: ${formatData(product.data_limit_gb, product.data_unlimited)}`, + ` Validity: ${product.validity_days} days`, + ` Charged: ${formatUsd(esim.charged_price_cents)}`, + ].join("\n"); + return structuredOk(text, { esim }); + }); + +// ── get_esim_qr ───────────────────────────────────────────────────────────── + +export const getEsimQrHandler = (http: HttpClient) => + wrapToolErrors(async (args: { esim_id: string }): Promise => { + const res = await http.request("GET", `/v1/esims/${args.esim_id}/qr.png`, { + expectBinary: true, + }); + if (!res.binary) return toolError("QR fetch returned no binary payload."); + const base64 = res.binary.toString("base64"); + return structuredWithImage( + `QR code for eSIM ${args.esim_id}. Scan with your device camera to install.`, + { esim_id: args.esim_id }, + { mimeType: "image/png", base64 }, + ); + }); + +// ── registration ──────────────────────────────────────────────────────────── + +export function registerEsimTools(server: McpServer, http: HttpClient) { server.tool( - "purchase_esim", - "Purchase an eSIM plan. Returns QR code and activation details.", + "search_esim_plans", + "Search global eSIM data plans. Each result includes the full plan shape (countries, region, data limit, validity, routing location, 5G/hotspot/calls/SMS/topup features) so a separate plan-details tool is unnecessary.", { - planId: z.string().describe("Plan ID to purchase (e.g., 'esim_jp_3g_7d')"), + country: z.string().optional().describe("ISO-3166 country code (e.g. 'JP')"), + min_data_gb: z.number().optional(), + min_days: z.number().optional(), + has_5g: z.boolean().optional(), + has_hotspot: z.boolean().optional(), + query: z.string().optional().describe("Substring search on plan title"), + limit: z.number().min(1).max(50).default(20), + cursor: z.string().optional(), }, - async ({ planId }) => { - const plan = getPlan(planId); - - if (!plan) { - return errorResponse( - `Plan not found: ${planId}. Use search_esim_plans to browse available plans.` - ); - } - - const costCents = Math.round(plan.retailPriceUsd * 100); - - if (!state.deductBalance(costCents, "esim_purchase", `eSIM: ${plan.title}`)) { - return errorResponse( - `Insufficient balance. Need ${formatUsd(costCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.` - ); - } - - const now = Date.now(); - const orderId = generateId("esm"); - const displayId = generateDisplayId("ESM"); - - const order: EsimOrder = { - id: orderId, - displayId, - planId: plan.id, - planTitle: plan.title, - countries: plan.countries, - dataLimitGb: plan.dataLimitGb, - dataUnlimited: plan.dataUnlimited, - validityDays: plan.validityDays, - dataUsedMb: 0, - status: "active", - retailPriceUsd: plan.retailPriceUsd, - qrCodeData: `https://sandbox.voidmob.com/esim/qr/${orderId}`, - activationCode: generateActivationCode(), - iccid: generateIccid(), - isTopup: false, - parentOrderId: null, - supportsTopup: plan.supportsTopup, - expiresAt: now + plan.validityDays * 86400000, - createdAt: now, - }; - - state.esimOrders.set(orderId, order); - - const lines = [ - `eSIM purchased!`, - ``, - ` Order ID: ${orderId}`, - ` Display ID: ${displayId}`, - ` Plan: ${plan.title}`, - ` Countries: ${plan.countries.join(", ")}`, - ` Data: ${formatData(plan.dataLimitGb, plan.dataUnlimited)}`, - ` Duration: ${plan.validityDays} days`, - ` Cost: $${plan.retailPriceUsd.toFixed(2)}`, - ` Balance: ${formatUsd(state.balanceCents)}`, - ``, - ` QR Code: ${order.qrCodeData}`, - ` Activation Code: ${order.activationCode}`, - ` ICCID: ${order.iccid}`, - ``, - `Setup steps:`, - ` 1. Scan the QR code or enter the activation code in your device settings`, - ` 2. Select the eSIM line and enable data roaming`, - ` 3. The plan activates on first data usage`, - ``, - `Use get_esim_usage with order ID "${orderId}" to check data consumption.`, - ]; - - return textResponse(lines.join("\n")); - } + searchEsimPlansHandler(http), ); server.tool( - "get_esim_usage", - "Check data usage and status for an eSIM order.", - { - orderId: z.string().describe("Order ID returned from purchase_esim"), - }, - async ({ orderId }) => { - const order = state.esimOrders.get(orderId); - - if (!order) { - return errorResponse(`Order not found: ${orderId}`); - } - - const now = Date.now(); - - if (order.status === "active" && now >= order.expiresAt) { - order.status = "expired"; - } - - // Simulate usage: ~0.1 GB/hour = ~102.4 MB/hour, cap at 95% of total - const hoursElapsed = (now - order.createdAt) / 3600000; - const simulatedUsageMb = hoursElapsed * 102.4; - - if (order.dataUnlimited || order.dataLimitGb === null) { - order.dataUsedMb = Math.round(simulatedUsageMb * 100) / 100; - } else { - const totalMb = order.dataLimitGb * 1024; - const maxUsageMb = totalMb * 0.95; - order.dataUsedMb = Math.round(Math.min(simulatedUsageMb, maxUsageMb) * 100) / 100; - } - - const esimStatus = order.status === "active" ? "active" : "expired"; - - const lines = [ - `eSIM Usage`, - ``, - ` esimStatus: ${esimStatus}`, - ``, - ` Package: ${order.planTitle}`, - ` Used: ${formatMb(order.dataUsedMb)}`, - ]; - - if (!order.dataUnlimited && order.dataLimitGb !== null) { - const totalMb = order.dataLimitGb * 1024; - const remainingMb = Math.max(0, totalMb - order.dataUsedMb); - const pct = Math.min(100, (order.dataUsedMb / totalMb) * 100); - - lines[lines.length - 1] = ` Used: ${formatMb(order.dataUsedMb)} / ${formatMb(totalMb)} (${pct.toFixed(1)}%)`; - lines.push(` Remaining: ${formatMb(remainingMb)}`); - } - - lines.push( - ` Expires: ${order.status === "expired" ? "Expired" : new Date(order.expiresAt).toISOString().slice(0, 16)}`, - ); - - if (order.status === "active") { - lines.push(` Time left: ${formatTimeRemaining(order.expiresAt)}`); - } + "purchase_esim", + "Purchase an eSIM plan. Quote-then-commit: the tool fetches the live price and ties max_price_cents to it so you never pay above what you saw.", + { plan_id: z.string().describe("prod_xxx from search_esim_plans") }, + purchaseEsimHandler(http), + ); - return textResponse(lines.join("\n")); - } + server.tool( + "get_esim_status", + "Read an eSIM's current status, plan info, and data usage. Combines GET /v1/esims/:id + /usage in one parallel call.", + { esim_id: z.string() }, + getEsimStatusHandler(http), ); server.tool( "topup_esim", - "Browse available top-up products or purchase a top-up for an active eSIM.", + "Browse top-up products (omit topup_product_id) or purchase a specific top-up (supply topup_product_id) for an active eSIM.", { - orderId: z.string().describe("Order ID of the eSIM to top up"), - topupProductId: z - .string() - .optional() - .describe("Top-up product ID to purchase. Omit to browse available top-ups."), + esim_id: z.string(), + topup_product_id: z.string().optional(), }, - async ({ orderId, topupProductId }) => { - const order = state.esimOrders.get(orderId); - - if (!order) { - return errorResponse(`Order not found: ${orderId}`); - } - - if (order.status !== "active") { - return errorResponse( - `Order ${orderId} is ${order.status}. Only active orders can be topped up.` - ); - } - - if (!order.supportsTopup) { - return errorResponse( - `Order ${orderId} (${order.planTitle}) does not support top-ups.` - ); - } - - if (!topupProductId) { - const products = getTopupProducts(order.planId); - - if (products.length === 0) { - return errorResponse( - `No top-up products available for plan ${order.planTitle}.` - ); - } - - let text = `Available top-ups for ${order.planTitle} (order ${orderId}):\n\n`; - - for (const p of products) { - text += ` ${p.title} (${p.id})\n`; - text += ` Data: ${p.dataLimitGb} GB\n`; - text += ` Duration: ${p.validityDays} days\n`; - text += ` Price: $${p.retailPriceUsd.toFixed(2)}\n\n`; - } - - text += `Use topup_esim with orderId and topupProductId to purchase.`; - - return textResponse(text); - } - - const product = getTopupProduct(topupProductId); - - if (!product) { - return errorResponse( - `Top-up product not found: ${topupProductId}. Use topup_esim without topupProductId to browse available options.` - ); - } - - const costCents = Math.round(product.retailPriceUsd * 100); - - if (!state.deductBalance(costCents, "esim_topup", `eSIM top-up: ${product.title} on ${orderId}`)) { - return errorResponse( - `Insufficient balance. Need ${formatUsd(costCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.` - ); - } - - const now = Date.now(); - const newOrderId = generateId("esm"); - const displayId = generateDisplayId("ESM"); - - const topupOrder: EsimOrder = { - id: newOrderId, - displayId, - planId: order.planId, - planTitle: product.title, - countries: order.countries, - dataLimitGb: product.dataLimitGb, - dataUnlimited: false, - validityDays: product.validityDays, - dataUsedMb: 0, - status: "active", - retailPriceUsd: product.retailPriceUsd, - qrCodeData: `https://sandbox.voidmob.com/esim/qr/${newOrderId}`, - activationCode: generateActivationCode(), - iccid: order.iccid, - isTopup: true, - parentOrderId: orderId, - supportsTopup: false, - expiresAt: now + product.validityDays * 86400000, - createdAt: now, - }; - - state.esimOrders.set(newOrderId, topupOrder); - - const text = [ - `Top-up purchased!`, - ``, - ` Top-up order: ${newOrderId}`, - ` Display ID: ${displayId}`, - ` Product: ${product.title}`, - ` Data: ${product.dataLimitGb} GB`, - ` Duration: ${product.validityDays} days`, - ` Cost: $${product.retailPriceUsd.toFixed(2)}`, - ` Parent order: ${orderId}`, - ` Balance: ${formatUsd(state.balanceCents)}`, - ``, - `The top-up data has been added to your eSIM.`, - ].join("\n"); + topupEsimHandler(http), + ); - return textResponse(text); - } + server.tool( + "get_esim_qr", + "Fetch the activation QR code for an eSIM as an image. Most MCP clients render the image inline so the user can scan it directly.", + { esim_id: z.string() }, + getEsimQrHandler(http), ); } diff --git a/src/tools/geo.ts b/src/tools/geo.ts new file mode 100644 index 0000000..4782a61 --- /dev/null +++ b/src/tools/geo.ts @@ -0,0 +1,74 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { HttpClient } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; +import { GeoCountry, GeoRegion, GeoCity, GeoIsp } from "../client/types.js"; +import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js"; + +interface GeoKind { + key: "isps" | "cities" | "regions" | "countries"; + schema: z.ZodTypeAny; + label: string; + fmt: (item: never) => string; +} + +const KINDS: ReadonlyArray = [ + { + key: "isps", + schema: GeoIsp, + label: "ISP(s)", + fmt: ((i: z.infer) => ` ${i.name} (${i.available_nodes} nodes)`) as (item: never) => string, + }, + { + key: "cities", + schema: GeoCity, + label: "cities", + fmt: ((c: z.infer) => ` ${c.name} (${c.available_nodes} nodes)`) as (item: never) => string, + }, + { + key: "regions", + schema: GeoRegion, + label: "regions", + fmt: ((r: z.infer) => ` ${r.name} (${r.available_nodes} nodes)`) as (item: never) => string, + }, + { + key: "countries", + schema: GeoCountry, + label: "countries", + fmt: ((c: z.infer) => ` ${c.name} (${c.code}) ${c.available_nodes} nodes`) as (item: never) => string, + }, +]; + +export const getGeoHandler = (http: HttpClient) => + wrapToolErrors(async (args: { country?: string; region?: string; city?: string }): Promise => { + const q = new URLSearchParams(); + if (args.country) q.set("country", args.country); + if (args.region) q.set("region", args.region); + if (args.city) q.set("city", args.city); + const path = `/v1/geo${q.toString() ? `?${q}` : ""}`; + const data = await callApi>(http, "GET", path); + + for (const kind of KINDS) { + if (data[kind.key]) { + const items = z.array(kind.schema).parse(data[kind.key]); + return structuredOk( + `${items.length} ${kind.label}:\n${items.map((i) => kind.fmt(i as never)).join("\n")}`, + { [kind.key]: items }, + ); + } + } + return toolError("Unexpected geo response shape."); + }); + +export function registerGeoTools(server: McpServer, http: HttpClient) { + server.tool( + "get_geo", + "Cascading geo discovery for proxy list targeting. No params -> countries; country=US -> regions; country=US®ion=California -> cities; +city=Los%20Angeles -> ISPs.", + { + country: z.string().optional().describe("ISO 3166-1 alpha-2 (e.g., US)"), + region: z.string().optional(), + city: z.string().optional(), + }, + getGeoHandler(http), + ); +} diff --git a/src/tools/orders.ts b/src/tools/orders.ts index d49f003..a282416 100644 --- a/src/tools/orders.ts +++ b/src/tools/orders.ts @@ -1,179 +1,109 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { state } from "../sandbox/state.js"; +import { HttpClient } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; +import { Rental, Esim, Proxy } from "../client/types.js"; +import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js"; import { formatUsd } from "../utils/format.js"; -import { errorResponse, textResponse } from "../utils/response.js"; -interface OrderDetails { +interface OrderRow { + kind: "sms" | "esim" | "proxy"; id: string; - displayId: string; - type: "sms" | "esim" | "proxy"; status: string; - priceCents: number; - createdAt: number; - details: Record; + charged_price_cents: number; + created_at: string; + summary: string; } -export function registerOrdersTools(server: McpServer) { - server.tool( - "list_orders", - "List all orders across SMS, eSIM, and proxy services.", - { - type: z - .enum(["sms", "esim", "proxy"]) - .optional() - .describe("Filter by service type"), - status: z - .enum(["active", "completed", "cancelled", "expired"]) - .optional() - .describe("Filter by status"), - limit: z - .number() - .min(1) - .max(50) - .default(20) - .describe("Maximum number of orders to return (default: 20)"), - }, - async ({ type, status, limit }) => { - const now = Date.now(); - const orders: OrderDetails[] = []; - - if (!type || type === "sms") { - for (const rental of state.smsRentals.values()) { - if (rental.status === "active" && rental.expiresAt < now) { - rental.status = "expired"; - } - if (status && rental.status !== status) continue; - orders.push({ - id: rental.id, - displayId: rental.displayId, - type: "sms", - status: rental.status, - priceCents: rental.priceCents, - createdAt: rental.createdAt, - details: { - phoneNumber: rental.phoneNumber, - service: rental.service, - serviceName: rental.serviceName, - rentalType: rental.rentalType, - duration: rental.duration, - messageCount: rental.messages.length, - }, - }); - } - } - - if (!type || type === "esim") { - for (const order of state.esimOrders.values()) { - if (order.status === "active" && order.expiresAt < now) { - order.status = "expired"; - } - if (status && order.status !== status) continue; - orders.push({ - id: order.id, - displayId: order.displayId, - type: "esim", - status: order.status, - priceCents: Math.round(order.retailPriceUsd * 100), - createdAt: order.createdAt, - details: { - planTitle: order.planTitle, - dataLimitGb: order.dataLimitGb, - validityDays: order.validityDays, - countries: order.countries.join(", "), - }, - }); - } +export const listOrdersHandler = (http: HttpClient) => + wrapToolErrors(async (args: { kind?: "sms" | "esim" | "proxy"; limit?: number }): Promise => { + const limit = args.limit ?? 20; + const tasks: Promise[] = []; + if (!args.kind || args.kind === "sms") tasks.push(fetchRentals(http)); + if (!args.kind || args.kind === "esim") tasks.push(fetchEsims(http)); + if (!args.kind || args.kind === "proxy") tasks.push(fetchProxies(http)); + const settled = await Promise.allSettled(tasks); + const rows: OrderRow[] = []; + const warnings: string[] = []; + for (const r of settled) { + if (r.status === "fulfilled") rows.push(...r.value); + else { + const msg = r.reason instanceof Error ? r.reason.message : String(r.reason); + warnings.push(`(partial: ${msg})`); } - - if (!type || type === "proxy") { - for (const proxy of state.proxies.values()) { - if (proxy.status === "active" && proxy.expiresAt < now) { - proxy.status = "expired"; - } - if (status && proxy.status !== status) continue; - orders.push({ - id: proxy.id, - displayId: proxy.displayId, - type: "proxy", - status: proxy.status, - priceCents: proxy.priceCents, - createdAt: proxy.createdAt, - details: { - type: proxy.type, - country: proxy.country, - countryName: proxy.countryName, - carrier: proxy.carrier, - carrierName: proxy.carrierName, - protocol: proxy.protocol, - }, - }); - } - } - - if (orders.length === 0) { - return errorResponse("No orders found."); + } + rows.sort((a, b) => b.created_at.localeCompare(a.created_at)); + const page = rows.slice(0, limit); + if (page.length === 0) { + // Distinguish a genuinely empty account from a fan-out where every + // branch failed (e.g. a schema mismatch throwing out of every fetch). + // Surfacing the warnings avoids the misleading "No orders found." + if (warnings.length > 0) { + return toolError(`Could not load orders. ${warnings.join(" ")}`); } + return toolError("No orders found."); + } + const text = [ + `${rows.length} order(s)${rows.length > limit ? ` (showing ${limit})` : ""}:`, + ``, + ...page.map( + (r) => + ` [${r.kind.toUpperCase().padEnd(5)}] ${r.id.padEnd(20)} ${r.status.padEnd(14)} ${formatUsd(r.charged_price_cents).padStart(8)} ${r.created_at.slice(0, 16)} ${r.summary}`, + ), + warnings.length ? `\n${warnings.join("\n")}` : "", + ] + .filter(Boolean) + .join("\n"); + return structuredOk(text, { orders: page }); + }); - orders.sort((a, b) => b.createdAt - a.createdAt); - const limited = orders.slice(0, limit); - - const typeBadge: Record = { - sms: "[SMS]", - esim: "[eSIM]", - proxy: "[Proxy]", - }; - - let text = `${orders.length} order(s)${orders.length > limit ? ` (showing ${limit})` : ""}:\n`; - - for (const o of limited) { - const date = new Date(o.createdAt).toISOString().slice(0, 16).replace("T", " "); - const badge = typeBadge[o.type]; - - text += `\n ${badge} ${o.displayId}`; - text += `\n ID: ${o.id}`; - text += `\n Status: ${o.status} | Price: ${formatUsd(o.priceCents)}`; +async function fetchRentals(http: HttpClient): Promise { + const data = await callApi(http, "GET", "/v1/rentals"); + const items = z.array(Rental).parse(data); + return items.map((r) => ({ + kind: "sms" as const, + id: r.id, + status: r.status, + charged_price_cents: r.charged_price_cents, + created_at: r.created_at, + summary: `${r.service_name} ${r.phone_number} ${r.duration ?? ""}`, + })); +} - if (o.type === "sms") { - const d = o.details as { - phoneNumber: string; - service: string; - serviceName: string; - rentalType: string; - duration: string | null; - messageCount: number; - }; - text += `\n Service: ${d.serviceName} (${d.service})`; - text += `\n Phone: ${d.phoneNumber} | Type: ${d.rentalType}`; - if (d.duration) text += ` | Duration: ${d.duration}`; - text += `\n Messages: ${d.messageCount}`; - } else if (o.type === "esim") { - const d = o.details as { - planTitle: string; - dataLimitGb: number | null; - validityDays: number; - countries: string; - }; - text += `\n Plan: ${d.planTitle}`; - text += `\n Data: ${d.dataLimitGb !== null ? `${d.dataLimitGb} GB` : "Unlimited"} | Validity: ${d.validityDays} days`; - text += `\n Countries: ${d.countries}`; - } else if (o.type === "proxy") { - const d = o.details as { - type: string; - country: string; - countryName: string; - carrier: string; - carrierName: string; - protocol: string; - }; - text += `\n Type: ${d.type} | Protocol: ${d.protocol}`; - text += `\n Location: ${d.countryName} (${d.country}) | Carrier: ${d.carrierName}`; - } +async function fetchEsims(http: HttpClient): Promise { + const data = await callApi<{ esims: unknown[] }>(http, "GET", "/v1/esims"); + const items = z.array(Esim).parse(data.esims); + return items.map((e) => ({ + kind: "esim" as const, + id: e.id, + status: e.status, + charged_price_cents: e.charged_price_cents, + created_at: e.created_at, + summary: `${e.countries.join(",")} ${e.data_unlimited || e.data_limit_gb == null ? "unlim" : `${e.data_limit_gb}GB`}`, + })); +} - text += `\n Created: ${date}\n`; - } +async function fetchProxies(http: HttpClient): Promise { + const data = await callApi<{ proxies: unknown[] }>(http, "GET", "/v1/proxies"); + const items = z.array(Proxy).parse(data.proxies); + return items.map((p) => ({ + kind: "proxy" as const, + id: p.id, + status: p.status, + charged_price_cents: p.charged_price_cents, + created_at: p.created_at ?? "", + summary: `${p.data_gb_total}GB ${p.lists.length} list(s)`, + })); +} - return textResponse(text); - } +export function registerOrdersTools(server: McpServer, http: HttpClient) { + server.tool( + "list_orders", + "List the user's active and past orders across SMS rentals, eSIMs, and proxies. Note: ephemeral verifications (20-min single-SMS) are NOT listable - the rental id you got from rent_number is your handle to them.", + { + kind: z.enum(["sms", "esim", "proxy"]).optional().describe("Filter by kind"), + limit: z.number().min(1).max(100).default(20), + }, + listOrdersHandler(http), ); } diff --git a/src/tools/proxy.ts b/src/tools/proxy.ts index dadf3a1..f9a9f3e 100644 --- a/src/tools/proxy.ts +++ b/src/tools/proxy.ts @@ -1,617 +1,400 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { state } from "../sandbox/state.js"; -import type { ProxyList } from "../sandbox/state.js"; -import { validateCountry, ToolError, generateId } from "../utils/validation.js"; -import { - formatUsd, - formatGb, - formatTimeRemaining, - generateDisplayId, - generateProxyCredentials, - generateIp, - generateConnectionString, - generateOpenvpnConfig, - generateVlessUri, -} from "../utils/format.js"; -import { errorResponse, textResponse } from "../utils/response.js"; -import { searchProducts, getProduct } from "../mock-data/proxy.js"; - -export function registerProxyTools(server: McpServer) { +import { HttpClient, HttpError, NetworkError } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; +import { newIdempotencyKey } from "../client/idempotency.js"; +import { Proxy, ProxyPlan, ProxyList, type ProxyPlan as ProxyPlanT } from "../client/types.js"; +import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js"; +import { formatUsd } from "../utils/format.js"; + +async function fetchProxyPlan(http: HttpClient, planId: string): Promise { + const plansData = await callApi<{ plans: unknown[] }>(http, "GET", `/v1/proxy_plans`); + const plans = z.array(ProxyPlan).parse(plansData.plans); + return plans.find((p) => p.id === planId) ?? null; +} + +// ── search_proxies ────────────────────────────────────────────────────────── + +export const searchProxiesHandler = (http: HttpClient) => + wrapToolErrors(async (args: { + country?: string; + min_data_gb?: number; + }): Promise => { + const q = new URLSearchParams(); + if (args.country) q.set("country", args.country); + if (args.min_data_gb !== undefined) q.set("min_gb", String(args.min_data_gb)); + const path = `/v1/proxy_plans${q.toString() ? `?${q}` : ""}`; + const data = await callApi<{ plans: unknown[] }>(http, "GET", path); + const plans = z.array(ProxyPlan).parse(data.plans); + if (plans.length === 0) return toolError("No proxy plans matched your filters."); + const text = [ + `Found ${plans.length} proxy plan(s):`, + ``, + ...plans.map((p) => + ` ${p.name.padEnd(36)} ${p.id.padEnd(20)} ${p.type.padEnd(10)} ${(p.country_name ?? p.country ?? "global").padEnd(16)} ${p.data_gb}GB ${p.duration_days}d ${formatUsd(p.quoted_price_cents)}`, + ), + ].join("\n"); + return structuredOk(text, { proxy_plans: plans }); + }); + +// ── purchase_proxy ────────────────────────────────────────────────────────── + +export const purchaseProxyHandler = (http: HttpClient) => + wrapToolErrors(async (args: { plan_id: string }): Promise => { + const plan = await fetchProxyPlan(http, args.plan_id); + if (!plan) { + return toolError( + `Plan '${args.plan_id}' not found. Use search_proxies to list available plans.`, + ); + } + const out = await callApi<{ proxy: unknown }>(http, "POST", "/v1/proxies", { + body: { plan_id: args.plan_id, max_price_cents: plan.quoted_price_cents }, + idempotencyKey: newIdempotencyKey(), + }); + const proxy = Proxy.parse(out.proxy); + return structuredOk( + `Proxy ${proxy.id} provisioning. Status: ${proxy.status}. Poll get_proxy_status until active.`, + { proxy }, + ); + }); + +// ── get_proxy_status ──────────────────────────────────────────────────────── + +export const getProxyStatusHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string }): Promise => { + // The core GET is the source of truth (and enforces auth/ownership). Usage + // and the NoList gateway are best-effort enrichment: degrade either to null + // on ANY API/network error so a transient secondary failure never sinks a + // status read. Non-API throws (bugs) still propagate. + const degradeToNull = (e: unknown) => { + if (e instanceof HttpError || e instanceof NetworkError) return null; + throw e; + }; + const [coreRaw, usageRaw, nolistRaw] = await Promise.all([ + callApi<{ proxy: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}`), + callApi<{ usage: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}/usage`).catch(degradeToNull), + // Idempotent get-or-create for the package-level NoList gateway. A stable + // per-proxy key (not a fresh UUID) means concurrent/repeat status polls + // dedup to a single provisioning instead of racing to overwrite the + // gateway password. + callApi<{ proxy: unknown }>(http, "POST", `/v1/proxies/${args.proxy_id}/nolist_credentials`, { + idempotencyKey: `nolist-${args.proxy_id}`, + }).catch(degradeToNull), + ]); + // Prefer the nolist response (gateway populated once active) over the core + // GET, whose gateway is null until nolist credentials are provisioned. + const proxy = Proxy.parse((nolistRaw?.proxy as unknown) ?? coreRaw.proxy); + const lines = [ + `Proxy ${proxy.id}`, + ``, + ` Status: ${proxy.status}`, + ` Data: ${(proxy.data_bytes_used / 1024 / 1024 / 1024).toFixed(2)} GB / ${proxy.data_gb_total} GB`, + ` Expires: ${proxy.expires_at}`, + ]; + if (proxy.rotation_url) lines.push(` Rotation URL: ${proxy.rotation_url}`); + if (proxy.gateway) { + lines.push( + ``, + ` Gateway:`, + ` Host: ${proxy.gateway.host}`, + ` Port: ${proxy.gateway.port}`, + ` Protocol: ${proxy.gateway.protocol}`, + ` User: ${proxy.gateway.username}`, + ` Password: ${proxy.gateway.password}`, + ); + } else { + lines.push(``, ` Gateway: (not yet provisioned)`); + } + return structuredOk(lines.join("\n"), { + proxy, + usage: usageRaw?.usage ?? null, + nolist_credentials: proxy.gateway, + }); + }); + +// ── rotate_proxy_ip ───────────────────────────────────────────────────────── + +export const rotateProxyIpHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string }): Promise => { + const out = await callApi<{ proxy_id: string; rotated_at: string; current_ip: string | null }>( + http, + "POST", + `/v1/proxies/${args.proxy_id}/rotate_ip`, + { idempotencyKey: newIdempotencyKey() }, + ); + return structuredOk( + `Rotated ${out.proxy_id} at ${out.rotated_at}. New IP: ${out.current_ip ?? "(unknown)"}`, + { proxy_id: out.proxy_id, rotated_at: out.rotated_at, current_ip: out.current_ip }, + ); + }); + +// ── renew_proxy ───────────────────────────────────────────────────────────── + +export const renewProxyHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string }): Promise => { + const coreRaw = await callApi<{ proxy: unknown }>( + http, + "GET", + `/v1/proxies/${args.proxy_id}`, + ); + const proxy = Proxy.parse(coreRaw.proxy); + if (!proxy.plan_id) { + return toolError(`Proxy ${args.proxy_id} has no plan_id; renewal not available.`); + } + const plan = await fetchProxyPlan(http, proxy.plan_id); + if (!plan) return toolError(`Original plan ${proxy.plan_id} no longer available.`); + const out = await callApi<{ proxy: unknown }>( + http, + "POST", + `/v1/proxies/${args.proxy_id}/renew`, + { + body: { max_price_cents: plan.quoted_price_cents }, + idempotencyKey: newIdempotencyKey(), + }, + ); + return structuredOk(`Proxy ${args.proxy_id} renewed.`, { proxy: Proxy.parse(out.proxy) }); + }); + +// ── topup_proxy ───────────────────────────────────────────────────────────── + +export const topupProxyHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string; additional_gb: number }): Promise => { + // Quote: derive per-GB price from the proxy's original plan, then tie + // max_price_cents to (perGb * additional_gb) so we never pay above quote. + const coreRaw = await callApi<{ proxy: unknown }>( + http, + "GET", + `/v1/proxies/${args.proxy_id}`, + ); + const proxy = Proxy.parse(coreRaw.proxy); + if (!proxy.plan_id) { + return toolError(`Proxy ${args.proxy_id} has no plan_id; top-up not available.`); + } + const plan = await fetchProxyPlan(http, proxy.plan_id); + if (!plan) return toolError(`Original plan ${proxy.plan_id} no longer available.`); + if (!plan.data_gb || plan.data_gb <= 0) { + return toolError(`Plan ${plan.id} has no GB allowance; top-up not available.`); + } + const maxPriceCents = Math.round((plan.quoted_price_cents / plan.data_gb) * args.additional_gb); + const out = await callApi<{ proxy: unknown }>( + http, + "POST", + `/v1/proxies/${args.proxy_id}/topup`, + { + body: { additional_gb: args.additional_gb, max_price_cents: maxPriceCents }, + idempotencyKey: newIdempotencyKey(), + }, + ); + // The topup response body carries only the refreshed proxy (no per-topup + // charged amount), so report the quoted ceiling we tied - the strict-tie + // contract guarantees the actual charge did not exceed it. + const refreshed = Proxy.parse(out.proxy); + return structuredOk( + `Topped up ${args.proxy_id} by ${args.additional_gb} GB (up to ${formatUsd(maxPriceCents)}).`, + { proxy: refreshed }, + ); + }); + +// ── regenerate_proxy_password ─────────────────────────────────────────────── + +export const regenerateProxyPasswordHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string }): Promise => { + const out = await callApi<{ proxy: unknown }>( + http, + "POST", + `/v1/proxies/${args.proxy_id}/regenerate_password`, + { idempotencyKey: newIdempotencyKey() }, + ); + const proxy = Proxy.parse(out.proxy); + const password = proxy.gateway?.password ?? "(no gateway)"; + return structuredOk(`New password for ${args.proxy_id}: ${password}`, { proxy }); + }); + +// ── list_proxy_lists ──────────────────────────────────────────────────────── + +export const listProxyListsHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string }): Promise => { + const coreRaw = await callApi<{ proxy: unknown }>(http, "GET", `/v1/proxies/${args.proxy_id}`); + const proxy = Proxy.parse(coreRaw.proxy); + const lists = proxy.lists; + if (lists.length === 0) return toolError(`No proxy lists on ${args.proxy_id}.`); + const text = [ + `Lists for ${args.proxy_id}:`, + ``, + ...lists.map((l) => { + const geo = l.countries?.length + ? l.countries.join(",") + : [l.country, l.region, l.city, l.isp].filter(Boolean).join("/") || "world"; + const rot = + l.rotation_period_seconds === 0 + ? "per-request" + : l.rotation_period_seconds === -1 + ? "sticky" + : `${l.rotation_period_seconds}s`; + return ` ${l.name} (${l.id}) geo=${geo} rotation=${rot} mode=${l.rotation_mode}`; + }), + ].join("\n"); + return structuredOk(text, { lists }); + }); + +// ── create_proxy_list ─────────────────────────────────────────────────────── + +export const createProxyListHandler = (http: HttpClient) => + wrapToolErrors(async (args: { + proxy_id: string; + name: string; + country?: string; + countries?: string[]; + region?: string; + city?: string; + isp?: string; + zip?: string; + rotation_period_seconds?: number; + rotation_mode?: "instant" | "delayed_5s" | "no_rotation_on_fail"; + format?: string; + }): Promise => { + // Geo: country (single) XOR countries (2-30). region/city/isp/zip only + // valid with a single country. Mirror the API's validation locally to + // fail fast without a wasted round-trip. + const hasCountry = !!args.country; + const hasCountries = !!args.countries && args.countries.length > 0; + if (!hasCountry && !hasCountries) { + return toolError("Provide either country (single) or countries (2-30)."); + } + if (hasCountry && hasCountries) { + return toolError("country and countries are mutually exclusive."); + } + if (hasCountries && (args.region || args.city || args.isp || args.zip)) { + return toolError("region/city/isp/zip are only valid with a single country, not countries."); + } + const body: Record = { + name: args.name, + rotation_period_seconds: args.rotation_period_seconds ?? 0, + rotation_mode: args.rotation_mode ?? "instant", + format: args.format ?? "login_pass_host_port", + }; + if (hasCountry) { + body.country = args.country; + if (args.region) body.region = args.region; + if (args.city) body.city = args.city; + if (args.isp) body.isp = args.isp; + if (args.zip) body.zip = args.zip; + } else { + body.countries = args.countries; + } + const out = await callApi<{ list: unknown }>(http, "POST", `/v1/proxies/${args.proxy_id}/lists`, { + body, + idempotencyKey: newIdempotencyKey(), + }); + const list = ProxyList.parse(out.list); + const credLines = list.credentials + ? [` Username: ${list.credentials.username}`, ` Password: ${list.credentials.password}`] + : [` Credentials: (provisioning - active within 1-2 minutes)`]; + const text = [`Created list ${list.id}.`, ...credLines, ...list.entries.map((e) => ` ${e}`)].join("\n"); + return structuredOk(text, { list }); + }); + +// ── delete_proxy_list ─────────────────────────────────────────────────────── + +export const deleteProxyListHandler = (http: HttpClient) => + wrapToolErrors(async (args: { proxy_id: string; list_id: string }): Promise => { + await callApi(http, "DELETE", `/v1/proxies/${args.proxy_id}/lists/${args.list_id}`, { + idempotencyKey: newIdempotencyKey(), + }); + return structuredOk(`List ${args.list_id} deleted.`, { proxy_id: args.proxy_id, list_id: args.list_id }); + }); + +// ── registration ──────────────────────────────────────────────────────────── + +export function registerProxyTools(server: McpServer, http: HttpClient) { server.tool( "search_proxies", - "Search available mobile proxy products by country or type.", + "Search available mobile proxy plans (shared rotating mobile IPs, billed by data). Each result includes the plan id, location, data allowance, duration, and price. Region/city/ISP targeting is configured per list after purchase via create_proxy_list.", { - country: z - .string() - .optional() - .describe("ISO 3166-1 alpha-2 country code (e.g., US, GB, DE)"), - type: z - .enum(["shared", "dedicated_standard", "dedicated_premium"]) - .optional() - .describe("Proxy type: shared (pay-per-GB), dedicated standard, or dedicated premium"), + country: z.string().optional().describe("ISO-3166-1 alpha-2 (e.g. 'US'). Many plans are worldwide and match any country."), + min_data_gb: z.number().optional().describe("Minimum included data allowance in GB"), }, - async ({ country, type }) => { - try { - const validatedCountry = country ? validateCountry(country) : undefined; - const results = searchProducts({ country: validatedCountry, type }); - - if (results.length === 0) { - return errorResponse( - "No proxy products found matching your criteria. Try a different country or type." - ); - } - - let text = `Found ${results.length} proxy product(s):\n\n`; - for (const p of results) { - text += ` ${p.name}\n`; - text += ` ID: ${p.id}\n`; - text += ` Type: ${p.type}\n`; - text += ` Country: ${p.country} (${p.countryName})\n`; - text += ` Carrier: ${p.carrier} (${p.carrierName})\n`; - if (p.dataGb !== null) { - text += ` Data: ${formatGb(p.dataGb)}\n`; - } - text += ` Duration: ${p.durationDays} days (${p.period})\n`; - text += ` Price: ${formatUsd(p.priceCents)}\n`; - if (p.features.length > 0) { - text += ` Features: ${p.features.join(", ")}\n`; - } - text += `\n`; - } - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + searchProxiesHandler(http), ); server.tool( "purchase_proxy", - "Purchase a mobile proxy. Shared (pay-per-GB), dedicated standard, or dedicated premium.", - { - productId: z.string().describe("Product ID from search_proxies"), - }, - async ({ productId }) => { - try { - const product = getProduct(productId); - if (!product) { - return errorResponse( - `Product not found: ${productId}. Use search_proxies to find available products.` - ); - } - - if ( - !state.deductBalance( - product.priceCents, - "proxy_purchase", - `Proxy: ${product.name}` - ) - ) { - return errorResponse( - `Insufficient balance. Need ${formatUsd(product.priceCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.` - ); - } - - const creds = generateProxyCredentials(product.country); - const ip = generateIp(); - const now = Date.now(); - - const protocol: "http" | "socks5" | "vless" = - product.type === "shared" - ? "http" - : product.type === "dedicated_standard" - ? "socks5" - : "vless"; - - const lists: ProxyList[] = []; - if (product.type === "shared") { - const r = () => Math.random().toString(36).substring(2, 8); - lists.push({ - id: generateId("lst"), - name: "Default", - login: `vm_${r()}`, - password: r() + r(), - country: null, - region: null, - city: null, - isp: null, - locationPreset: "world_mix", - countries: null, - rotationPeriod: 0, - }); - } - - const proxyId = generateId("prx"); - const entry = { - id: proxyId, - displayId: generateDisplayId("PRX"), - type: product.type, - status: "active" as const, - proxyHost: creds.host, - proxyPort: creds.port, - socksPort: - product.type === "dedicated_standard" || product.type === "dedicated_premium" - ? creds.socksPort - : null, - proxyUsername: creds.username, - proxyPassword: creds.password, - protocol, - country: product.country, - countryName: product.countryName, - carrier: product.carrier, - carrierName: product.carrierName, - currentIp: ip, - isOnline: true, - dataTotal: product.type === "shared" ? product.dataGb : null, - dataUsed: product.type === "shared" ? 0 : null, - rotationInterval: null, - lastRotatedAt: null, - autoRenew: false, - expiresAt: now + product.durationDays * 24 * 60 * 60 * 1000, - createdAt: now, - priceCents: product.priceCents, - features: product.features, - lists, - }; - - state.proxies.set(proxyId, entry); - - const connectionString = generateConnectionString( - creds.host, - creds.port, - creds.username, - creds.password - ); - - const lines = [ - `Proxy purchased!`, - ``, - ` Order ID: ${proxyId}`, - ` Display ID: ${entry.displayId}`, - ` Product: ${product.name}`, - ` Type: ${product.type}`, - ` Protocol: ${protocol}`, - ` Country: ${product.country} (${product.countryName})`, - ` Carrier: ${product.carrier} (${product.carrierName})`, - ` Cost: ${formatUsd(product.priceCents)}`, - ` Expires: ${formatTimeRemaining(entry.expiresAt)}`, - ]; - - if (product.type === "shared" && product.dataGb !== null) { - lines.push(` Data: ${formatGb(product.dataGb)}`); - } - - lines.push( - ``, - ` Connection:`, - ` Host: ${creds.host}`, - ` Port: ${creds.port}` - ); - - if (entry.socksPort !== null) { - lines.push(` SOCKS Port: ${entry.socksPort}`); - } - - lines.push( - ` Username: ${creds.username}`, - ` Password: ${creds.password}`, - ` String: ${connectionString}`, - ` Current IP: ${ip}` - ); - - if (lists.length > 0) { - const list = lists[0]; - const listConn = generateConnectionString(creds.host, creds.port, list.login, list.password); - lines.push( - ``, - ` Default Proxy List:`, - ` List ID: ${list.id}`, - ` Login: ${list.login}`, - ` Password: ${list.password}`, - ` Preset: ${list.locationPreset}`, - ` Rotation: per-request`, - ` Connection: ${listConn}` - ); - } - - lines.push( - ``, - `Use get_proxy_status to check status, rotate_proxy to rotate IP (dedicated only).` - ); - - return textResponse(lines.join("\n")); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + "Purchase a proxy plan. Returns 202 Accepted with status=provisioning; the gateway becomes active in 1-2 minutes. Poll get_proxy_status until status='active'.", + { plan_id: z.string() }, + purchaseProxyHandler(http), ); server.tool( "get_proxy_status", - "Check status, bandwidth, and connection details for a proxy.", - { - proxyId: z.string().describe("Proxy order ID returned from purchase_proxy"), - }, - async ({ proxyId }) => { - try { - const proxy = state.proxies.get(proxyId); - if (!proxy) { - return errorResponse(`Proxy not found: ${proxyId}`); - } - - const now = Date.now(); - - if (proxy.type === "shared" && proxy.dataTotal !== null) { - const hoursElapsed = (now - proxy.createdAt) / 3600000; - const simulatedUsage = hoursElapsed * 0.05; - const maxUsage = proxy.dataTotal * 0.9; - proxy.dataUsed = Math.round(Math.min(simulatedUsage, maxUsage) * 100) / 100; - } - - const lines = [ - `Proxy Status - ${proxy.carrierName} (${proxy.countryName})`, - ``, - ` Order ID: ${proxy.id}`, - ` Display ID: ${proxy.displayId}`, - ` Status: ${proxy.status}`, - ` Type: ${proxy.type}`, - ` Protocol: ${proxy.protocol}`, - ` Country: ${proxy.country} (${proxy.countryName})`, - ` Carrier: ${proxy.carrier} (${proxy.carrierName})`, - ` Online: ${proxy.isOnline ? "yes" : "no"}`, - ` Current IP: ${proxy.currentIp}`, - ` Expires: ${formatTimeRemaining(proxy.expiresAt)}`, - ` Auto-renew: ${proxy.autoRenew ? "on" : "off"}`, - ]; - - if (proxy.type === "shared" && proxy.dataTotal !== null && proxy.dataUsed !== null) { - const remaining = Math.max(0, proxy.dataTotal - proxy.dataUsed); - lines.push( - ``, - ` Bandwidth:`, - ` Used: ${formatGb(proxy.dataUsed)}`, - ` Remaining: ${formatGb(remaining)}`, - ` Total: ${formatGb(proxy.dataTotal)}` - ); - } - - if (proxy.type !== "shared") { - lines.push( - ``, - ` Rotation:`, - ` Interval: ${proxy.rotationInterval !== null ? `${proxy.rotationInterval}s` : "manual"}`, - ` Last rotated: ${proxy.lastRotatedAt ? new Date(proxy.lastRotatedAt).toISOString() : "never"}` - ); - } - - lines.push( - ``, - ` Connection:`, - ` Host: ${proxy.proxyHost}`, - ` Port: ${proxy.proxyPort}` - ); - - if (proxy.socksPort !== null) { - lines.push(` SOCKS Port: ${proxy.socksPort}`); - } - - lines.push( - ` Username: ${proxy.proxyUsername}`, - ` Password: ${proxy.proxyPassword}` - ); - - if (proxy.features.length > 0) { - lines.push(``, ` Features: ${proxy.features.join(", ")}`); - } - - if (proxy.type === "shared" && proxy.lists.length > 0) { - lines.push(``, ` Proxy Lists: ${proxy.lists.length}`); - for (const list of proxy.lists) { - lines.push(` - ${list.name} (${list.id}) preset=${list.locationPreset}`); - } - } - - return textResponse(lines.join("\n")); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + "Read a proxy's status, usage, and gateway credentials in one call.", + { proxy_id: z.string() }, + getProxyStatusHandler(http), ); server.tool( - "rotate_proxy", - "Rotate a proxy to get a new IP address. Works on dedicated proxies only.", - { - proxyId: z.string().describe("Proxy order ID to rotate"), - }, - async ({ proxyId }) => { - try { - const proxy = state.proxies.get(proxyId); - if (!proxy) { - return errorResponse(`Proxy not found: ${proxyId}`); - } - - if (proxy.status !== "active") { - return errorResponse( - `Proxy ${proxyId} is ${proxy.status}. Only active proxies can be rotated.` - ); - } - - if (proxy.type === "shared") { - return errorResponse( - `Shared proxies rotate automatically per-request. Use proxy lists to control rotation. Only dedicated proxies support manual rotation.` - ); - } - - const oldIp = proxy.currentIp; - const newIp = generateIp(); - proxy.currentIp = newIp; - proxy.lastRotatedAt = Date.now(); - - const text = [ - `IP rotated!`, - ``, - ` Order ID: ${proxy.id}`, - ` Old IP: ${oldIp}`, - ` New IP: ${newIp}`, - ` Last rotated: ${new Date(proxy.lastRotatedAt).toISOString()}`, - ].join("\n"); - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + "rotate_proxy_ip", + "Rotate a dedicated proxy to a new IP. Shared proxies rotate per-request through their lists - use create_proxy_list / list_proxy_lists instead.", + { proxy_id: z.string() }, + rotateProxyIpHandler(http), ); server.tool( - "get_proxy_lists", - "Get proxy lists for a shared proxy order.", - { - orderId: z.string().describe("Shared proxy order ID"), - }, - async ({ orderId }) => { - try { - const proxy = state.proxies.get(orderId); - if (!proxy) { - return errorResponse(`Proxy not found: ${orderId}`); - } - - if (proxy.type !== "shared") { - return errorResponse( - `Proxy lists are only available for shared proxies. This is a ${proxy.type} proxy.` - ); - } - - if (proxy.lists.length === 0) { - return errorResponse(`No proxy lists found for order ${orderId}.`); - } - - let text = `Proxy Lists for ${proxy.displayId} (${proxy.countryName}):\n\n`; - for (const list of proxy.lists) { - const connStr = generateConnectionString( - proxy.proxyHost, - proxy.proxyPort, - list.login, - list.password - ); - text += ` ${list.name} (${list.id})\n`; - text += ` Login: ${list.login}\n`; - text += ` Password: ${list.password}\n`; - text += ` Preset: ${list.locationPreset}\n`; - if (list.countries) { - text += ` Countries: ${list.countries.join(", ")}\n`; - } - text += ` Rotation: ${list.rotationPeriod === 0 ? "per-request" : list.rotationPeriod === -1 ? "sticky" : `${list.rotationPeriod}s`}\n`; - if (list.country) text += ` Country: ${list.country}\n`; - if (list.region) text += ` Region: ${list.region}\n`; - if (list.city) text += ` City: ${list.city}\n`; - if (list.isp) text += ` ISP: ${list.isp}\n`; - text += ` Connection: ${connStr}\n`; - text += `\n`; - } - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + "renew_proxy", + "Extend a proxy's expiry by purchasing another period. Quote-then-commit.", + { proxy_id: z.string() }, + renewProxyHandler(http), ); server.tool( - "create_proxy_list", - "Create a new geo-targeted proxy list for a shared proxy order.", + "topup_proxy", + "Add more data to a shared proxy plan. Quote-then-commit: derives per-GB price from the proxy's original plan and ties max_price_cents to (per_gb * additional_gb).", { - orderId: z.string().describe("Shared proxy order ID"), - name: z.string().describe("Name for the proxy list"), - locationPreset: z - .enum(["world_mix", "north_america", "europe", "asia", "latin_america", "custom"]) - .default("world_mix") - .describe("Location preset for the list"), - countries: z - .array(z.string()) - .optional() - .describe("Country codes (required when preset is 'custom')"), - rotationPeriod: z - .number() - .default(0) - .describe("Rotation period: 0=per-request, -1=sticky, N=seconds"), + proxy_id: z.string(), + additional_gb: z.number().int().positive(), }, - async ({ orderId, name, locationPreset, countries, rotationPeriod }) => { - try { - const proxy = state.proxies.get(orderId); - if (!proxy) { - return errorResponse(`Proxy not found: ${orderId}`); - } - - if (proxy.type !== "shared") { - return errorResponse( - `Proxy lists are only available for shared proxies. This is a ${proxy.type} proxy.` - ); - } - - if (locationPreset === "custom" && (!countries || countries.length === 0)) { - return errorResponse( - `Countries must be provided when using the 'custom' location preset.` - ); - } - - const validatedCountries = countries - ? countries.map((c) => validateCountry(c)) - : null; - - const r = () => Math.random().toString(36).substring(2, 8); - const list: ProxyList = { - id: generateId("lst"), - name, - login: `vm_${r()}`, - password: r() + r(), - country: null, - region: null, - city: null, - isp: null, - locationPreset, - countries: validatedCountries, - rotationPeriod, - }; - - proxy.lists.push(list); - - const connStr = generateConnectionString( - proxy.proxyHost, - proxy.proxyPort, - list.login, - list.password - ); - - const rotationLabel = - rotationPeriod === 0 - ? "per-request" - : rotationPeriod === -1 - ? "sticky" - : `${rotationPeriod}s`; - - const lines = [ - `Proxy list created!`, - ``, - ` List ID: ${list.id}`, - ` Name: ${list.name}`, - ` Login: ${list.login}`, - ` Password: ${list.password}`, - ` Preset: ${locationPreset}`, - ]; - - if (validatedCountries) { - lines.push(` Countries: ${validatedCountries.join(", ")}`); - } - - lines.push( - ` Rotation: ${rotationLabel}`, - ` Connection: ${connStr}`, - ``, - `Total lists for this order: ${proxy.lists.length}` - ); - - return textResponse(lines.join("\n")); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + topupProxyHandler(http), ); server.tool( - "get_openvpn_config", - "Get OpenVPN configuration file for a dedicated proxy.", + "regenerate_proxy_password", + "Rotate the main proxy gateway password. Returns the new credentials.", + { proxy_id: z.string() }, + regenerateProxyPasswordHandler(http), + ); + + server.tool( + "list_proxy_lists", + "List proxy lists for a shared proxy (geo-targeted sub-pools that share the proxy's bandwidth).", + { proxy_id: z.string() }, + listProxyListsHandler(http), + ); + + server.tool( + "create_proxy_list", + "Create a new geo-targeted proxy list on a shared proxy. Provide either a single country (with optional region/city/isp/zip subfilters) or a countries array (2-30, mutually exclusive with the subfilters). To edit an existing list, delete it and create a new one.", { - orderId: z.string().describe("Dedicated proxy order ID"), + proxy_id: z.string(), + name: z.string(), + country: z.string().optional().describe("ISO-3166-1 alpha-2, lowercased. Mutually exclusive with countries."), + countries: z.array(z.string()).optional().describe("2-30 ISO-3166-1 alpha-2 codes. Mutually exclusive with country/subfilters."), + region: z.string().optional().describe("Only valid with a single country."), + city: z.string().optional().describe("Only valid with a single country."), + isp: z.string().optional().describe("Only valid with a single country."), + zip: z.string().optional().describe("Only valid with a single country."), + rotation_period_seconds: z.number().int().default(0).describe("0=per-request, -1=sticky, N=seconds (max 86400)"), + rotation_mode: z.enum(["instant", "delayed_5s", "no_rotation_on_fail"]).default("instant"), + format: z.string().default("login_pass_host_port").describe("Output format for entries[] (e.g. login_pass_host_port, http_url, socks5_url)"), }, - async ({ orderId }) => { - try { - const proxy = state.proxies.get(orderId); - if (!proxy) { - return errorResponse(`Proxy not found: ${orderId}`); - } - - if (proxy.type === "shared") { - return errorResponse( - `OpenVPN config is only available for dedicated proxies. This is a shared proxy.` - ); - } - - const { config, filename } = generateOpenvpnConfig( - proxy.proxyHost, - proxy.proxyUsername, - proxy.proxyPassword, - proxy.country, - proxy.carrierName - ); - - const text = [ - `OpenVPN Configuration - ${proxy.displayId}`, - ``, - ` Filename: ${filename}`, - ``, - `--- ${filename} ---`, - config, - `--- end ---`, - ].join("\n"); - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + createProxyListHandler(http), ); server.tool( - "get_vless_config", - "Get VLESS connection URI for a dedicated premium proxy.", + "delete_proxy_list", + "Delete a proxy list. The list's credentials stop working immediately.", { - orderId: z.string().describe("Dedicated premium proxy order ID"), + proxy_id: z.string(), + list_id: z.string(), }, - async ({ orderId }) => { - try { - const proxy = state.proxies.get(orderId); - if (!proxy) { - return errorResponse(`Proxy not found: ${orderId}`); - } - - if (proxy.type !== "dedicated_premium") { - return errorResponse( - `VLESS config is only available for dedicated premium proxies. This is a ${proxy.type} proxy.` - ); - } - - const { uri, uuid } = generateVlessUri( - proxy.proxyHost, - proxy.proxyPort, - proxy.country - ); - - const text = [ - `VLESS Configuration - ${proxy.displayId}`, - ``, - ` Host: ${proxy.proxyHost}`, - ` Port: ${proxy.proxyPort}`, - ` UUID: ${uuid}`, - ``, - ` URI: ${uri}`, - ].join("\n"); - - return textResponse(text); - } catch (e) { - if (e instanceof ToolError) return errorResponse(e.message); - throw e; - } - } + deleteProxyListHandler(http), ); } diff --git a/src/tools/sms.ts b/src/tools/sms.ts index c20d2be..15e944d 100644 --- a/src/tools/sms.ts +++ b/src/tools/sms.ts @@ -1,461 +1,316 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { state, SmsRental } from "../sandbox/state.js"; -import { generateId } from "../utils/validation.js"; +import { HttpClient } from "../client/http.js"; +import { callApi } from "../client/call-api.js"; import { - formatUsd, - generatePhoneNumber, - generateVerificationCode, - generateDisplayId, - formatTimeRemaining, -} from "../utils/format.js"; -import { errorResponse, textResponse } from "../utils/response.js"; -import { searchServices, getService, smsServices } from "../mock-data/sms.js"; - -const DURATION_DAYS: Record = { - "3D": 3, - "7D": 7, - "14D": 14, - "30D": 30, -}; - -const REUSE_COST_CENTS = 50; - -export function registerSmsTools(server: McpServer) { - server.tool( - "search_sms_services", - "Search available US non-VoIP SMS services with pricing for verification, long-term rental, and dedicated numbers.", - { - query: z - .string() - .optional() - .describe("Search by service name (e.g., 'telegram', 'whatsapp')"), - }, - async ({ query }) => { - const results = searchServices(query); - - if (results.length === 0) { - return errorResponse( - "No services found matching your query. Try a different search term or omit the query to see all services." - ); - } - - let text = `Found ${results.length} US non-VoIP SMS service(s):\n\n`; - text += `${"Service".padEnd(22)} ${"ID".padEnd(6)} ${"Verify".padStart(8)} ${"3-Day".padStart(8)} ${"7-Day".padStart(8)} ${"14-Day".padStart(8)} ${"30-Day".padStart(8)} ${"Dedicated".padStart(10)} Icon\n`; - text += `${"─".repeat(22)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(4)}\n`; - - for (const s of results) { - text += `${s.serviceName.padEnd(22)} ${s.id.padEnd(6)} ${formatUsd(s.priceUsdCents).padStart(8)} ${formatUsd(s.ltr3PriceCents).padStart(8)} ${formatUsd(s.ltr7PriceCents).padStart(8)} ${formatUsd(s.ltr14PriceCents).padStart(8)} ${formatUsd(s.ltr30PriceCents).padStart(8)} ${formatUsd(s.dedicatedPriceCents).padStart(10)} ${s.hasIcon ? "Yes" : "No"}\n`; - } - - return textResponse(text); + Verification, + VerificationCancelResult, + Rental, + ServicesResponse, + type Verification as VerificationT, + type Rental as RentalT, +} from "../client/types.js"; +import { structuredOk, toolError, wrapToolErrors, type ToolResult } from "../utils/render.js"; +import { formatUsd, formatTimeRemaining } from "../utils/format.js"; +import { newIdempotencyKey } from "../client/idempotency.js"; +import { + VER_PREFIX, + REN_PREFIX, + isVerificationId, + isRentalId, + INVALID_RENTAL_ID, +} from "../constants/rental-id.js"; + +// The catalog service id for the 28-day dedicated number tier. +const DEDICATED_SERVICE_ID = "svc_dedicated_28d"; + +// ── search_sms_services ───────────────────────────────────────────────────── + +export const searchSmsServicesHandler = (http: HttpClient) => + wrapToolErrors(async (args: { query?: string }): Promise => { + const raw = await callApi(http, "GET", "/v1/services"); + const parsed = ServicesResponse.parse(raw); + let services = parsed.services; + if (args.query) { + const q = args.query.toLowerCase(); + services = services.filter((s) => s.name.toLowerCase().includes(q)); } - ); - - server.tool( - "get_sms_price", - "Get all pricing tiers for a specific US non-VoIP SMS service.", - { - service: z.string().describe("Service ID (e.g., 'wa', 'tg', 'go')"), - }, - async ({ service }) => { - const svc = getService(service); - - if (!svc) { - return errorResponse( - `Service "${service}" not found. Use search_sms_services to find available options.` - ); - } + if (services.length === 0) return toolError("No services matched."); + const text = [ + `${services.length} SMS service(s):`, + ``, + ...services.slice(0, 50).map((s) => + ` ${s.name.padEnd(20)} ${s.id.padEnd(14)} verify=${formatUsd(s.quoted_price_cents)}${ + s.ltr_7d_price_cents ? ` 7d=${formatUsd(s.ltr_7d_price_cents)}` : "" + }`, + ), + ].join("\n"); + return structuredOk(text, { services }); + }); + +// ── get_rental ────────────────────────────────────────────────────────────── + +export const getRentalHandler = (http: HttpClient) => + wrapToolErrors(async (args: { rental_id: string }): Promise => { + const id = args.rental_id; + if (isVerificationId(id)) { + const raw = await callApi<{ verification: unknown }>(http, "GET", `/v1/verifications/${id}`); + const v = Verification.parse(raw.verification); + return structuredOk(renderVerification(v), { verification: v }); + } + if (isRentalId(id)) { + const raw = await callApi(http, "GET", `/v1/rentals/${id}`); + const r = Rental.parse(raw); + return structuredOk(renderRental(r), { rental: r }); + } + return toolError(INVALID_RENTAL_ID(id)); + }); - const text = [ - `${svc.serviceName} (${svc.id}) - US Non-VoIP`, - ``, - ` Verification (20min): ${formatUsd(svc.priceUsdCents)}`, - ` Long-Term Rental:`, - ` 3-day: ${formatUsd(svc.ltr3PriceCents)}`, - ` 7-day: ${formatUsd(svc.ltr7PriceCents)}`, - ` 14-day: ${formatUsd(svc.ltr14PriceCents)}`, - ` 30-day: ${formatUsd(svc.ltr30PriceCents)}`, - ` Dedicated (28 days): ${formatUsd(svc.dedicatedPriceCents)}`, - ``, - ` Has icon: ${svc.hasIcon ? "Yes" : "No"}`, - ].join("\n"); +// ── rent_number ───────────────────────────────────────────────────────────── - return textResponse(text); +export const rentNumberHandler = (http: HttpClient) => + wrapToolErrors(async (args: { service_id: string; kind?: "verification" | "rental" | "dedicated"; duration?: "3d" | "7d" | "14d" | "30d" }): Promise => { + const kind = args.kind ?? "verification"; + if (kind === "rental" && !args.duration) { + return toolError("rent_number kind='rental' requires a duration (3d|7d|14d|30d)."); } - ); - - server.tool( - "rent_number", - "Rent a US non-VoIP phone number. Supports verification (20min), long-term rental (3-30 days), and dedicated numbers (28 days, all services).", - { - service: z.string().describe("Service ID (e.g., 'wa', 'tg', 'go')"), - rentalType: z - .enum(["verification", "rental", "dedicated"]) - .default("verification") - .describe("Type of rental (default: verification)"), - duration: z - .enum(["3D", "7D", "14D", "30D"]) - .optional() - .describe("Duration for long-term rentals (required when rentalType is 'rental')"), - autoRenew: z - .boolean() - .default(false) - .describe("Enable auto-renewal (only for rental/dedicated)"), - }, - async ({ service, rentalType, duration, autoRenew }) => { - const svc = getService(service); + // Quote against the live catalog. + const services = await callApi(http, "GET", "/v1/services"); + const parsed = ServicesResponse.parse(services); + if (kind === "verification") { + const svc = parsed.services.find((s) => s.id === args.service_id); if (!svc) { - return errorResponse( - `Service "${service}" not found. Use search_sms_services to find available options.` - ); + return toolError(`Service '${args.service_id}' not found. Use search_sms_services to list available services.`); } + const created = await callApi<{ verification: unknown }>(http, "POST", "/v1/verifications", { + body: { service_id: args.service_id, max_price_cents: svc.quoted_price_cents }, + idempotencyKey: newIdempotencyKey(), + }); + const v = Verification.parse(created.verification); + return structuredOk(`Verification ${v.id} created.\n\n${renderVerification(v)}`, { verification: v }); + } - if (rentalType === "rental" && !duration) { - return errorResponse( - `Duration is required for long-term rentals. Choose one of: 3D, 7D, 14D, 30D.` - ); + // Long-term + dedicated both POST /v1/rentals with an uppercase duration. + // Dedicated is the dedicated 28-day catalog service (svc_dedicated_28d, 28D). + let serviceId: string; + let duration: "3D" | "7D" | "14D" | "30D" | "28D"; + let quotedCents: number; + if (kind === "dedicated") { + serviceId = DEDICATED_SERVICE_ID; + duration = "28D"; + const ded = parsed.services.find((s) => s.id === DEDICATED_SERVICE_ID); + if (!ded || !ded.ltr_28d_price_cents) { + return toolError("Dedicated 28-day numbers are not currently available."); } - - let priceCents: number; - let txType: "sms_verification" | "sms_rental" | "sms_dedicated"; - let expiryMs: number; - - const now = Date.now(); - - if (rentalType === "verification") { - priceCents = svc.priceUsdCents; - txType = "sms_verification"; - expiryMs = 20 * 60 * 1000; - } else if (rentalType === "rental") { - const durationKey = duration!; - const priceMap: Record = { - "3D": svc.ltr3PriceCents, - "7D": svc.ltr7PriceCents, - "14D": svc.ltr14PriceCents, - "30D": svc.ltr30PriceCents, - }; - priceCents = priceMap[durationKey]; - txType = "sms_rental"; - expiryMs = DURATION_DAYS[durationKey] * 86400000; - } else { - // dedicated - priceCents = svc.dedicatedPriceCents; - txType = "sms_dedicated"; - expiryMs = 28 * 86400000; + quotedCents = ded.ltr_28d_price_cents; + } else { + serviceId = args.service_id; + const svc = parsed.services.find((s) => s.id === args.service_id); + if (!svc) { + return toolError(`Service '${args.service_id}' not found. Use search_sms_services to list available services.`); } - - const desc = rentalType === "verification" - ? `SMS verification: ${svc.serviceName}` - : rentalType === "rental" - ? `SMS ${duration} rental: ${svc.serviceName}` - : `SMS dedicated: ${svc.serviceName}`; - - if (!state.deductBalance(priceCents, txType, desc)) { - return errorResponse( - `Insufficient balance. Need ${formatUsd(priceCents)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.` - ); + const tier = args.duration as "3d" | "7d" | "14d" | "30d"; + const priceByTier: Record = { + "3d": svc.ltr_3d_price_cents, + "7d": svc.ltr_7d_price_cents, + "14d": svc.ltr_14d_price_cents, + "30d": svc.ltr_30d_price_cents, + }; + const v = priceByTier[tier]; + if (!v) { + return toolError(`Long-term rental ${args.duration} is not offered for ${svc.name}. Try a different duration or kind='dedicated'.`); } + quotedCents = v; + duration = tier.toUpperCase() as "3D" | "7D" | "14D" | "30D"; + } + // /v1/rentals returns the rental object flat (no { rental: ... } wrapper) + const created = await callApi(http, "POST", "/v1/rentals", { + body: { service_id: serviceId, duration, max_price_cents: quotedCents }, + idempotencyKey: newIdempotencyKey(), + }); + const r = Rental.parse(created); + return structuredOk(`Rental ${r.id} created.\n\n${renderRental(r)}`, { rental: r }); + }); + +// ── cancel_rental ─────────────────────────────────────────────────────────── + +export const cancelRentalHandler = (http: HttpClient) => + wrapToolErrors(async (args: { rental_id: string }): Promise => { + const id = args.rental_id; + if (isVerificationId(id)) { + const out = await callApi<{ verification: unknown }>(http, "POST", `/v1/verifications/${id}/cancel`, { + idempotencyKey: newIdempotencyKey(), + }); + const v = VerificationCancelResult.parse(out.verification); + const refund = v.refunded_cents && v.refunded_cents > 0 ? ` Refunded ${formatUsd(v.refunded_cents)}.` : ""; + return structuredOk(`Verification ${v.id} cancelled.${refund}`, { verification: v }); + } + if (isRentalId(id)) { + const out = await callApi(http, "DELETE", `/v1/rentals/${id}`, { + idempotencyKey: newIdempotencyKey(), + }); + const r = Rental.parse(out); + return structuredOk(`Rental ${r.id} cancelled.`, { rental: r }); + } + return toolError(INVALID_RENTAL_ID(id)); + }); - const rentalId = generateId("sms"); - const phoneNumber = generatePhoneNumber(); - const displayId = generateDisplayId("SMS"); - - const rental: SmsRental = { - id: rentalId, - displayId, - phoneNumber, - service: svc.id, - serviceName: svc.serviceName, - rentalType, - duration: rentalType === "rental" ? duration! : null, - autoRenew: rentalType === "verification" ? false : autoRenew, - paidUntil: rentalType === "verification" ? null : now + expiryMs, - status: "active", - messages: [], - expiresAt: now + expiryMs, - createdAt: now, - priceCents, - reuseCounter: 0, - }; +// ── reuse_number ──────────────────────────────────────────────────────────── - state.smsRentals.set(rentalId, rental); +export const reuseNumberHandler = (http: HttpClient) => + wrapToolErrors(async (args: { rental_id: string; paid?: boolean }): Promise => { + const id = args.rental_id; + if (!isVerificationId(id)) { + return toolError(`reuse_number requires a verification id (${VER_PREFIX}xxx). Got '${id}'.`); + } + const path = args.paid ? `/v1/verifications/${id}/reuse/paid` : `/v1/verifications/${id}/reuse`; + const out = await callApi<{ verification: unknown }>(http, "POST", path, { + idempotencyKey: newIdempotencyKey(), + }); + const v = Verification.parse(out.verification); + return structuredOk(renderVerification(v), { verification: v }); + }); + +// ── re_rent_rental ────────────────────────────────────────────────────────── + +export const reRentRentalHandler = (http: HttpClient) => + wrapToolErrors(async (args: { rental_id: string }): Promise => { + const id = args.rental_id; + if (!isRentalId(id)) { + return toolError(`re_rent_rental requires ${REN_PREFIX}xxx. Got '${id}'.`); + } + // No request body: re-rents the same number for the same duration at the + // current price. Only valid when re_rent_available is true on the rental. + const out = await callApi(http, "POST", `/v1/rentals/${id}/re_rent`, { + idempotencyKey: newIdempotencyKey(), + }); + const r = Rental.parse(out); + return structuredOk(`Re-rented ${r.id}.\n\n${renderRental(r)}`, { rental: r }); + }); + +// ── toggle_auto_renew ─────────────────────────────────────────────────────── + +export const toggleAutoRenewHandler = (http: HttpClient) => + wrapToolErrors(async (args: { rental_id: string; auto_renew: boolean }): Promise => { + const id = args.rental_id; + if (!isRentalId(id)) { + return toolError(`toggle_auto_renew requires ${REN_PREFIX}xxx. Got '${id}'.`); + } + const out = await callApi(http, "POST", `/v1/rentals/${id}/auto_renew`, { + body: { auto_renew: args.auto_renew }, + idempotencyKey: newIdempotencyKey(), + }); + const r = Rental.parse(out); + return structuredOk(`Auto-renew on ${r.id} is now ${r.auto_renew ? "on" : "off"}.`, { rental: r }); + }); + +// ── render helpers ────────────────────────────────────────────────────────── + +export function renderVerification(v: VerificationT): string { + const lines = [ + `Verification ${v.id}`, + ``, + ` Phone: ${v.phone_number}`, + ` Service: ${v.service_name} (${v.service_id})`, + ` Status: ${v.status}`, + ` Charged: ${formatUsd(v.charged_price_cents)}`, + ` Expires: ${formatTimeRemaining(new Date(v.expires_at).getTime())}`, + ]; + if (v.status === "code_received" && v.code) { + lines.push(``, ` Code received: ${v.code}`); + if (v.code_received_at) lines.push(` At: ${v.code_received_at}`); + } else if (v.status === "waiting_for_code") { + lines.push(``, ` No code yet. Try get_rental again in 10-30s.`); + } + return lines.join("\n"); +} - const lines = [ - `Number rented!`, - ``, - ` Rental ID: ${rentalId}`, - ` Display ID: ${displayId}`, - ` Number: ${phoneNumber}`, - ` Service: ${svc.serviceName}`, - ` Type: ${rentalType}${rentalType === "rental" ? ` (${duration})` : ""}`, - ` Cost: ${formatUsd(priceCents)}`, - ` Expires: ${formatTimeRemaining(now + expiryMs)}`, - ]; +export function renderRental(r: RentalT): string { + const label = r.duration === "28D" ? "dedicated" : "rental"; + const lines = [ + `Rental ${r.id} (${label})`, + ``, + ` Phone: ${r.phone_number}`, + ` Service: ${r.service_name} (${r.service_id})`, + ` Status: ${r.status}`, + ` Charged: ${formatUsd(r.charged_price_cents)}`, + ` Duration: ${r.duration ?? "-"}`, + ` Auto-renew: ${r.auto_renew ? "on" : "off"}`, + ` Paid until: ${r.paid_until ?? "-"}`, + ` Expires: ${formatTimeRemaining(new Date(r.expires_at).getTime())}`, + ]; + if (r.messages && r.messages.length > 0) { + lines.push(``, ` Messages (${r.messages.length}):`); + for (const m of r.messages) { + lines.push(` [${m.received_at.slice(11, 19)}] ${m.text}`); + if (m.code) lines.push(` Code: ${m.code}`); + } + } + return lines.join("\n"); +} - if (rentalType !== "verification") { - lines.push(` Auto-renew: ${autoRenew ? "On" : "Off"}`); - lines.push(` Paid until: ${new Date(now + expiryMs).toISOString().slice(0, 16)}`); - } +// ── registration ──────────────────────────────────────────────────────────── - lines.push(``); - lines.push(` Balance: ${formatUsd(state.balanceCents)}`); - lines.push(``); - lines.push(`Use get_messages with the rental ID to check for incoming SMS.`); +export function registerSmsTools(server: McpServer, http: HttpClient) { + server.tool( + "search_sms_services", + "Search available US non-VoIP SMS services with prices per row. Returns each service's verification price plus LTR/dedicated tiers when offered.", + { query: z.string().optional().describe("Substring filter on service name (e.g. 'telegram').") }, + searchSmsServicesHandler(http), + ); - return textResponse(lines.join("\n")); - } + server.tool( + "get_rental", + "Read a rental's current status and any messages received. Pass the ID you got from rent_number (ver_xxx for verifications, ren_xxx for long-term/dedicated). SMS codes typically arrive 10-60s after rent_number; poll this tool until status changes.", + { rental_id: z.string().describe("ver_xxx or ren_xxx") }, + getRentalHandler(http), ); server.tool( - "get_messages", - "Check for incoming SMS messages on a rented number.", + "rent_number", + "Rent a US non-VoIP phone number. kind='verification' (single SMS, 20min); kind='rental' (timed LTR with duration); kind='dedicated' (28-day all-services number). Quote-then-commit: the tool fetches the live price and ties max_price_cents to the quote so you never pay above what you saw.", { - rentalId: z.string().describe("Rental ID returned from rent_number"), + service_id: z.string().describe("svc_xxx from search_sms_services"), + kind: z.enum(["verification", "rental", "dedicated"]).default("verification"), + duration: z.enum(["3d", "7d", "14d", "30d"]).optional().describe("Required when kind='rental'"), }, - async ({ rentalId }) => { - const rental = state.smsRentals.get(rentalId); - - if (!rental) { - return errorResponse(`Rental not found: ${rentalId}`); - } - - if (rental.status === "active" && Date.now() >= rental.expiresAt) { - rental.status = "expired"; - } - - if (rental.status === "expired") { - return errorResponse( - `Rental ${rentalId} has expired. Use reuse_number to reactivate a verification rental.` - ); - } - - if (rental.status === "cancelled") { - return errorResponse(`Rental ${rentalId} has been cancelled.`); - } - - // Lazy mock: generate a message after 5 seconds - if (rental.messages.length === 0) { - const elapsed = Date.now() - rental.createdAt; - - if (elapsed < 5000) { - const text = [ - `No messages yet for ${rental.phoneNumber} (${rental.serviceName}).`, - ``, - ` Waiting for SMS... try again shortly.`, - ` Time since rental: ${Math.floor(elapsed / 1000)}s`, - ].join("\n"); - - return textResponse(text); - } - - // For dedicated numbers, the message comes from a random service - let msgService = rental.serviceName; - if (rental.rentalType === "dedicated") { - const randomSvc = smsServices[Math.floor(Math.random() * smsServices.length)]; - msgService = randomSvc.serviceName; - } - - const code = generateVerificationCode(); - rental.messages.push({ - id: generateId("msg"), - message_text: `Your ${msgService} verification code is: ${code}`, - code, - received_at: Date.now(), - read_at: null, - }); - - if (rental.rentalType === "verification") { - rental.status = "completed"; - } - } - - let text = `Messages for ${rental.phoneNumber} (${rental.serviceName}):\n\n`; - - for (const msg of rental.messages) { - const time = new Date(msg.received_at).toISOString().slice(11, 19); - const readStatus = msg.read_at ? "read" : "new"; - text += ` [${time}] (${readStatus})\n`; - text += ` ${msg.message_text}\n`; - if (msg.code) { - text += ` Code: ${msg.code}\n`; - } - text += `\n`; - - if (!msg.read_at) { - msg.read_at = Date.now(); - } - } - - text += `Total messages: ${rental.messages.length}`; - if (rental.status === "completed") { - text += `\nStatus: completed - use reuse_number to receive another SMS.`; - } - - return textResponse(text); - } + rentNumberHandler(http), ); server.tool( "cancel_rental", - "Cancel an SMS rental. Full refund for verification with no messages, no refund for LTR/dedicated.", - { - rentalId: z.string().describe("Rental ID to cancel"), - }, - async ({ rentalId }) => { - const rental = state.smsRentals.get(rentalId); - - if (!rental) { - return errorResponse(`Rental not found: ${rentalId}`); - } - - if (rental.status === "cancelled") { - return errorResponse(`Rental ${rentalId} is already cancelled.`); - } - - if (rental.status === "expired") { - return errorResponse(`Rental ${rentalId} has expired and cannot be cancelled.`); - } - - rental.status = "cancelled"; - - if (rental.rentalType === "verification" && rental.messages.length === 0) { - state.addBalance(rental.priceCents, "refund", `Refund: ${rental.serviceName} verification`); - - const text = [ - `Rental ${rentalId} cancelled.`, - ``, - ` Refund: ${formatUsd(rental.priceCents)} (no messages received)`, - ` New balance: ${formatUsd(state.balanceCents)}`, - ].join("\n"); - - return textResponse(text); - } - - const reason = rental.rentalType === "verification" - ? "messages were already received" - : `${rental.rentalType} rentals are non-refundable`; - - const text = [ - `Rental ${rentalId} cancelled.`, - ``, - ` No refund - ${reason}.`, - ].join("\n"); - - return textResponse(text); - } + "Cancel a rental. For verifications (ver_xxx) the API may refund if no message arrived. For long-term/dedicated rentals (ren_xxx) cancellation is typically non-refundable - check the response.", + { rental_id: z.string() }, + cancelRentalHandler(http), ); server.tool( "reuse_number", - "Reuse a completed or expired verification number to receive another SMS.", + "Reuse a completed/expired verification to receive another SMS. Free reuse is available when allow_reuse is true on the verification. Paid reuse ($0.50) is available when allow_paid_reuse is true.", { - rentalId: z.string().describe("Rental ID of a verification rental to reuse"), - paid: z - .boolean() - .default(false) - .describe("Use paid reuse ($0.50) for expired numbers"), + rental_id: z.string().describe("ver_xxx from a verification"), + paid: z.boolean().default(false), }, - async ({ rentalId, paid }) => { - const rental = state.smsRentals.get(rentalId); - - if (!rental) { - return errorResponse(`Rental not found: ${rentalId}`); - } - - if (rental.rentalType !== "verification") { - return errorResponse( - `Only verification rentals can be reused. This is a ${rental.rentalType} rental.` - ); - } - - if (rental.status === "active") { - return errorResponse( - `Rental ${rentalId} is still active. Wait for it to complete or expire before reusing.` - ); - } - - if (rental.status === "cancelled") { - return errorResponse(`Rental ${rentalId} has been cancelled and cannot be reused.`); - } - - if (!paid && rental.status === "expired") { - return errorResponse( - `Rental ${rentalId} has expired. Use paid reuse (paid: true) for ${formatUsd(REUSE_COST_CENTS)} to reactivate.` - ); - } - - if (paid) { - if (!state.deductBalance(REUSE_COST_CENTS, "sms_reuse", `SMS reuse: ${rental.serviceName}`)) { - return errorResponse( - `Insufficient balance. Need ${formatUsd(REUSE_COST_CENTS)} but have ${formatUsd(state.balanceCents)}. Use deposit to add funds.` - ); - } - } - - const now = Date.now(); - rental.status = "active"; - rental.expiresAt = now + 20 * 60 * 1000; - rental.messages = []; - rental.reuseCounter += 1; - - const lines = [ - `Number reused!`, - ``, - ` Rental ID: ${rentalId}`, - ` Number: ${rental.phoneNumber}`, - ` Service: ${rental.serviceName}`, - ` Reuse count: ${rental.reuseCounter}`, - ` Cost: ${paid ? formatUsd(REUSE_COST_CENTS) : "Free"}`, - ` Expires: ${formatTimeRemaining(rental.expiresAt)}`, - ` Balance: ${formatUsd(state.balanceCents)}`, - ``, - `Use get_messages to check for incoming SMS.`, - ]; + reuseNumberHandler(http), + ); - return textResponse(lines.join("\n")); - } + server.tool( + "re_rent_rental", + "Re-rent the same number for another period at the current price. Only works on an expired rental whose re_rent_available is true (the provider has not yet released the number). Re-uses the rental's original duration; no duration argument.", + { + rental_id: z.string().describe("ren_xxx from an expired LTR with re_rent_available=true"), + }, + reRentRentalHandler(http), ); server.tool( "toggle_auto_renew", - "Toggle auto-renewal for a long-term rental or dedicated number.", + "Turn auto-renewal on/off for an LTR or dedicated rental.", { - rentalId: z.string().describe("Rental ID of a long-term or dedicated rental"), + rental_id: z.string().describe("ren_xxx"), + auto_renew: z.boolean(), }, - async ({ rentalId }) => { - const rental = state.smsRentals.get(rentalId); - - if (!rental) { - return errorResponse(`Rental not found: ${rentalId}`); - } - - if (rental.rentalType === "verification") { - return errorResponse( - `Auto-renewal is not available for verification rentals. Only long-term and dedicated rentals support auto-renewal.` - ); - } - - rental.autoRenew = !rental.autoRenew; - - const lines = [ - `Auto-renewal ${rental.autoRenew ? "enabled" : "disabled"}.`, - ``, - ` Rental ID: ${rentalId}`, - ` Service: ${rental.serviceName}`, - ` Type: ${rental.rentalType}${rental.duration ? ` (${rental.duration})` : ""}`, - ` Auto-renew: ${rental.autoRenew ? "On" : "Off"}`, - ` Paid until: ${rental.paidUntil ? new Date(rental.paidUntil).toISOString().slice(0, 16) : "N/A"}`, - ` Renewal cost: ${formatUsd(rental.priceCents)}`, - ]; - - if (rental.autoRenew && state.balanceCents < rental.priceCents) { - lines.push(``); - lines.push(` WARNING: Balance (${formatUsd(state.balanceCents)}) is less than renewal cost (${formatUsd(rental.priceCents)}). Add funds before renewal date.`); - } - - return textResponse(lines.join("\n")); - } + toggleAutoRenewHandler(http), ); } diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts deleted file mode 100644 index e3efc55..0000000 --- a/src/tools/wallet.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { state } from "../sandbox/state.js"; -import { generateId } from "../utils/validation.js"; -import { - formatUsd, - generateMockWalletAddress, - generateMockCryptoAmount, -} from "../utils/format.js"; -import { textResponse } from "../utils/response.js"; - -export function registerWalletTools(server: McpServer) { - server.tool( - "get_balance", - "Get wallet balance.", - {}, - async () => { - state.resolvePendingDeposits(); - - const pendingDeposits = [...state.deposits.values()].filter( - (d) => d.status === "pending" - ); - - let text = `Balance: ${formatUsd(state.balanceCents)} (${state.balanceCents} cents)`; - - if (pendingDeposits.length > 0) { - text += `\n\nPending deposits: ${pendingDeposits.length}`; - for (const d of pendingDeposits) { - text += `\n - $${d.amount.toFixed(2)} ${d.currency} (auto-confirms in ~5s)`; - } - } - - return textResponse(text); - } - ); - - server.tool( - "deposit", - "Create a crypto deposit to add funds. Auto-confirms in ~5 seconds in sandbox.", - { - amount: z - .number() - .min(5) - .max(10000) - .describe("Amount in USD to deposit (min $5, max $10,000)"), - currency: z - .enum(["BTC", "ETH", "SOL", "LTC", "XMR", "TRX", "TON", "BNB", "USDT_TRX", "USDT_SOL", "USDC_SOL", "USDC_ETH"]) - .default("BTC") - .describe("Cryptocurrency to pay with (default: BTC)"), - }, - async ({ amount, currency }) => { - const invoiceId = generateId("inv"); - const now = Date.now(); - const walletAddress = generateMockWalletAddress(currency); - const qrCodeUrl = `https://sandbox.voidmob.com/pay/qr/${invoiceId}`; - const cryptoAmount = generateMockCryptoAmount(amount, currency); - const expiresAt = now + 30 * 60 * 1000; - - state.deposits.set(invoiceId, { - invoiceId, - amount, - currency, - walletAddress, - qrCodeUrl, - cryptoAmount, - status: "pending", - expiresAt, - createdAt: now, - }); - - const text = [ - `Deposit created!`, - ``, - ` Deposit ID: ${invoiceId}`, - ` Amount: $${amount.toFixed(2)} USD`, - ` Crypto amount: ${cryptoAmount} ${currency}`, - ` Wallet address: ${walletAddress}`, - ` QR code: ${qrCodeUrl}`, - ` Expires at: ${new Date(expiresAt).toISOString()}`, - ``, - `This is a sandbox deposit. It will auto-confirm in ~5 seconds.`, - `Call get_balance after a few seconds to see the updated balance.`, - ].join("\n"); - - return textResponse(text); - } - ); -} diff --git a/src/utils/format.ts b/src/utils/format.ts index af311bc..8e69a34 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -14,11 +14,6 @@ export function formatData(gb: number | null, unlimited: boolean): string { return formatGb(gb); } -export function formatMb(mb: number): string { - if (mb >= 1024) return formatGb(mb / 1024); - return `${mb.toFixed(0)} MB`; -} - export function formatTimeRemaining(expiryMs: number): string { const remaining = expiryMs - Date.now(); if (remaining <= 0) return "expired"; @@ -28,144 +23,3 @@ export function formatTimeRemaining(expiryMs: number): string { if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } - -// ── Generators ── - -function rand(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function randomHex(length: number): string { - return Array.from({ length }, () => rand(0, 15).toString(16)).join(""); -} - -function randomAlphanumeric(length: number): string { - const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - return Array.from({ length }, () => chars[rand(0, chars.length - 1)]).join(""); -} - -export function generateDisplayId(prefix: string): string { - return `${prefix}${randomAlphanumeric(7)}`; -} - -export function generatePhoneNumber(): string { - return `+1${rand(200, 999)}${rand(200, 999)}${rand(1000, 9999)}`; -} - -export function generateVerificationCode(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); -} - -export function generateIp(): string { - const o = () => rand(1, 254); - return `${o()}.${o()}.${o()}.${o()}`; -} - -export function generateProxyCredentials(country: string): { - host: string; port: number; socksPort: number; username: string; password: string; -} { - const r = () => Math.random().toString(36).substring(2, 8); - return { - host: `${country.toLowerCase()}.proxy.voidmob.com`, - port: 10000 + rand(0, 4999), - socksPort: 20000 + rand(0, 4999), - username: `vm_${r()}`, - password: r() + r(), - }; -} - -export function generateIccid(): string { - return `8901${rand(1000, 9999)}${rand(10000000, 99999999)}${rand(0, 9)}`; -} - -export function generateActivationCode(): string { - return `LPA:1$smdp.voidmob.com$${randomHex(32).toUpperCase()}`; -} - -export function generateMockWalletAddress(currency: string): string { - switch (currency) { - case "BTC": return `bc1q${randomHex(38)}`; - case "ETH": - case "BNB": - case "USDC_ETH": return `0x${randomHex(40)}`; - case "SOL": - case "USDT_SOL": - case "USDC_SOL": return randomAlphanumeric(44); - case "LTC": return `ltc1q${randomHex(38)}`; - case "XMR": return `4${randomAlphanumeric(94)}`; - case "TRX": - case "USDT_TRX": return `T${randomAlphanumeric(33)}`; - case "TON": return `UQ${randomAlphanumeric(46)}`; - default: return `0x${randomHex(40)}`; - } -} - -export function generateMockCryptoAmount(usdAmount: number, currency: string): string { - const rates: Record = { - BTC: 95000, ETH: 3800, SOL: 180, LTC: 90, - XMR: 170, TRX: 0.25, TON: 6, BNB: 600, - USDT_TRX: 1, USDT_SOL: 1, USDC_SOL: 1, USDC_ETH: 1, - }; - const rate = rates[currency] ?? 1; - return (usdAmount / rate).toFixed(rate >= 100 ? 8 : 4); -} - -export function generateConnectionString( - host: string, port: number, username: string, password: string -): string { - return `${username}:${password}@${host}:${port}`; -} - -export function generateVlessUri( - host: string, port: number, country: string -): { uri: string; uuid: string } { - const uuid = `${randomHex(8)}-${randomHex(4)}-4${randomHex(3)}-${randomHex(4)}-${randomHex(12)}`; - const uri = `vless://${uuid}@${host}:${port}?security=tls&type=tcp#VoidMob-${country}`; - return { uri, uuid }; -} - -export function generateOpenvpnConfig( - host: string, username: string, password: string, country: string, carrier: string -): { config: string; filename: string } { - const filename = `voidmob-${country.toLowerCase()}-${carrier.toLowerCase().replace(/\s+/g, "-")}.ovpn`; - const mockCert = btoa(`MOCK-CERTIFICATE-${randomHex(32)}`); - const config = `# VoidMob Proxy - OpenVPN Configuration -# ${country} / ${carrier} -# Generated for sandbox mode - -client -dev tun -proto udp -remote ${host} 1194 -resolv-retry infinite -nobind -persist-key -persist-tun - -auth-user-pass -# Username: ${username} -# Password: ${password} - -cipher AES-256-GCM -auth SHA256 -tls-client -remote-cert-tls server -verb 3 - - ------BEGIN CERTIFICATE----- -${mockCert} ------END CERTIFICATE----- - - - ------BEGIN OpenVPN Static key V1----- -${randomHex(64)} -${randomHex(64)} -${randomHex(64)} -${randomHex(64)} ------END OpenVPN Static key V1----- - -key-direction 1`; - return { config, filename }; -} diff --git a/src/utils/render.ts b/src/utils/render.ts new file mode 100644 index 0000000..dad9fa7 --- /dev/null +++ b/src/utils/render.ts @@ -0,0 +1,73 @@ +import { ZodError } from "zod"; +import { HttpError, NetworkError } from "../client/http.js"; +import { mapApiError } from "../client/errors.js"; + +export interface ToolResult { + // SDK CallToolResult includes a string index signature; mirroring it here lets + // factory handlers be passed straight to server.tool() without a cast. + [x: string]: unknown; + content: Array< + | { type: "text"; text: string } + | { type: "image"; mimeType: string; data: string } + >; + structuredContent?: Record; + isError?: boolean; +} + +export function structuredOk(text: string, structured: Record): ToolResult { + return { + content: [{ type: "text", text }], + structuredContent: structured, + }; +} + +export function structuredWithImage( + text: string, + structured: Record, + image: { mimeType: string; base64: string }, +): ToolResult { + return { + content: [ + { type: "text", text }, + { type: "image", mimeType: image.mimeType, data: image.base64 }, + ], + structuredContent: structured, + }; +} + +export function toolError(message: string): ToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +/** + * Wrap a tool handler so error surfaces become clean, white-labeled tool + * results instead of opaque protocol crashes: + * - HttpError / NetworkError -> agent-readable text via mapApiError. + * - ZodError (response shape we can't parse) -> a generic message that leaks + * no schema internals. The detail is logged to stderr only. We deliberately + * do NOT tell the caller to blindly retry: a money operation may have + * succeeded server-side even though we couldn't parse its response, so the + * caller should verify before re-running. + * - Anything else still propagates. + */ +export function wrapToolErrors( + fn: (args: A) => Promise, +): (args: A) => Promise { + return async (args: A) => { + try { + return await fn(args); + } catch (e) { + if (e instanceof HttpError || e instanceof NetworkError) { + return toolError(mapApiError(e)); + } + if (e instanceof ZodError) { + process.stderr.write(`[voidmob-mcp] response schema mismatch: ${e.message}\n`); + return toolError( + "The API returned an unexpected response the client could not parse. " + + "The operation may have completed - check your account or use list_orders / a get_* tool to verify before retrying.", + ); + } + throw e; + } + }; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts deleted file mode 100644 index 17505fb..0000000 --- a/src/utils/validation.ts +++ /dev/null @@ -1,38 +0,0 @@ -// ISO 3166-1 alpha-2 country codes (subset of most common) -const VALID_COUNTRIES = new Set([ - "US", "GB", "CA", "AU", "DE", "FR", "NL", "IT", "ES", "PT", - "BR", "MX", "AR", "CO", "CL", "PE", "JP", "KR", "CN", "IN", - "ID", "TH", "VN", "PH", "MY", "SG", "HK", "TW", "RU", "UA", - "PL", "CZ", "RO", "SE", "NO", "DK", "FI", "AT", "CH", "BE", - "IE", "NZ", "ZA", "EG", "NG", "KE", "IL", "TR", "SA", "AE", -]); - -export function validateCountry(country: string): string { - const upper = country.toUpperCase(); - if (!VALID_COUNTRIES.has(upper)) { - throw new ToolError( - `Invalid country code: ${country}. Use ISO 3166-1 alpha-2 (e.g., US, GB, JP).` - ); - } - return upper; -} - -export function requireField(value: unknown, fieldName: string): void { - if (value === undefined || value === null || value === "") { - throw new ToolError(`Missing required field: ${fieldName}`); - } -} - -export class ToolError extends Error { - constructor(message: string) { - super(message); - this.name = "ToolError"; - } -} - -let counter = 0; -export function generateId(prefix: string): string { - counter++; - const rand = Math.random().toString(36).substring(2, 8); - return `${prefix}_${rand}${counter}`; -} diff --git a/tests/client/errors.test.ts b/tests/client/errors.test.ts new file mode 100644 index 0000000..339d797 --- /dev/null +++ b/tests/client/errors.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { mapApiError } from "../../src/client/errors.js"; +import { HttpError, NetworkError } from "../../src/client/http.js"; + +describe("mapApiError", () => { + it("UNAUTHENTICATED → docs URL pointer", () => { + const err = new HttpError(401, "UNAUTHENTICATED", "req_x", undefined, "Authentication required."); + expect(mapApiError(err)).toContain("dashboard.voidmob.com/developers/api-keys"); + }); + + it("INSUFFICIENT_BALANCE → wallet URL pointer", () => { + const err = new HttpError(402, "INSUFFICIENT_BALANCE", "req_x", undefined, "..."); + expect(mapApiError(err)).toContain("dashboard.voidmob.com/wallet"); + }); + + it("RATE_LIMITED → retry hint", () => { + const err = new HttpError(429, "RATE_LIMITED", "req_x", undefined, "..."); + expect(mapApiError(err)).toMatch(/retry/i); + }); + + it("PRICE_OVER_CAP → quote/available delta", () => { + const err = new HttpError(409, "PRICE_OVER_CAP", "req_x", { max_price_cents: 35, available_price_cents: 42 }, "..."); + expect(mapApiError(err)).toContain("$0.35"); + expect(mapApiError(err)).toContain("$0.42"); + }); + + it("unknown API code → falls through to API message", () => { + const err = new HttpError(500, "WEIRD_NEW_CODE", "req_x", undefined, "Pass-through msg."); + expect(mapApiError(err)).toContain("Pass-through msg."); + }); + + it("NetworkError → connection text", () => { + const err = new NetworkError(new Error("ECONNREFUSED")); + expect(mapApiError(err)).toMatch(/could not reach/i); + }); + + it("always includes request_id when present", () => { + const err = new HttpError(500, "INTERNAL_ERROR", "req_abc123", undefined, "..."); + expect(mapApiError(err)).toContain("req_abc123"); + }); +}); diff --git a/tests/client/http.test.ts b/tests/client/http.test.ts new file mode 100644 index 0000000..f22e605 --- /dev/null +++ b/tests/client/http.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHttpClient, HttpError, NetworkError } from "../../src/client/http.js"; + +describe("createHttpClient", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + afterEach(() => vi.restoreAllMocks()); + + function jsonResponse(status: number, body: unknown, headers: Record = {}) { + return { + status, + headers: new Headers({ "Content-Type": "application/json", ...headers }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + } + + it("attaches Authorization, User-Agent, JSON Content-Type", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true, data: { ok: true } })); + const c = createHttpClient({ apiKey: "vmk_live_test", baseUrl: "https://x", debug: false, userAgent: "voidmob-mcp/test" }); + await c.request("GET", "/v1/me"); + const [_url, init] = fetchMock.mock.calls[0]; + expect(init.headers.get("Authorization")).toBe("Bearer vmk_live_test"); + expect(init.headers.get("User-Agent")).toBe("voidmob-mcp/test"); + expect(init.headers.get("Content-Type")).toBe("application/json"); + }); + + it("adds Idempotency-Key when opts.idempotencyKey is set", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(201, { success: true, data: {} })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await c.request("POST", "/v1/verifications", { body: { a: 1 }, idempotencyKey: "abc-123" }); + const [_url, init] = fetchMock.mock.calls[0]; + expect(init.headers.get("Idempotency-Key")).toBe("abc-123"); + }); + + it("returns parsed body on 2xx", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true, data: { id: 42 } })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + const res = await c.request("GET", "/v1/me"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: { id: 42 } }); + }); + + it("throws HttpError with code on 4xx envelope", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { + success: false, + error: { code: "UNAUTHENTICATED", message: "Authentication required.", request_id: "req_x", docs_url: "https://docs/x" }, + })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({ + name: "HttpError", + status: 401, + code: "UNAUTHENTICATED", + requestId: "req_x", + }); + }); + + it("retries GET 2x on 5xx then surfaces last error", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } })) + .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } })) + .mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({ status: 500 }); + expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 retries + }); + + it("does NOT retry POST", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "", request_id: "r", docs_url: "" } })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toMatchObject({ status: 500 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry 429", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(429, { success: false, error: { code: "RATE_LIMITED", message: "", request_id: "r", docs_url: "" } }, { "Retry-After": "30" })); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("GET", "/v1/me")).rejects.toMatchObject({ code: "RATE_LIMITED" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws NetworkError on fetch failure", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toBeInstanceOf(NetworkError); + }); + + it("returns binary body when expectBinary=true", async () => { + const buf = new Uint8Array([1, 2, 3]); + fetchMock.mockResolvedValueOnce({ + status: 200, + headers: new Headers({ "Content-Type": "image/png" }), + arrayBuffer: async () => buf.buffer, + }); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + const res = await c.request("GET", "/v1/esims/x/qr.png", { expectBinary: true }); + expect(res.binary).toBeInstanceOf(Buffer); + expect((res.binary as Buffer).length).toBe(3); + }); + + it("wraps arrayBuffer errors as NetworkError on binary path", async () => { + const brokenBinaryResponse = () => ({ + status: 200, + headers: new Headers({ "Content-Type": "image/png" }), + arrayBuffer: async () => { throw new TypeError("stream broken"); }, + }); + // GET retries NetworkError up to 3 attempts total - mock all of them + fetchMock + .mockResolvedValueOnce(brokenBinaryResponse()) + .mockResolvedValueOnce(brokenBinaryResponse()) + .mockResolvedValueOnce(brokenBinaryResponse()); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: false, userAgent: "ua" }); + await expect(c.request("GET", "/v1/esims/x/qr.png", { expectBinary: true })).rejects.toBeInstanceOf(NetworkError); + }); + + it("debug log does not crash on malformed error envelope shapes", async () => { + // {error: null} shape + fetchMock.mockResolvedValueOnce({ + status: 500, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ success: false, error: null }), + }); + const c = createHttpClient({ apiKey: "k", baseUrl: "https://x", debug: true, userAgent: "ua" }); + // Should throw HttpError, not blow up trying to read .code + await expect(c.request("POST", "/v1/x", { body: {} })).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/tests/client/idempotency.test.ts b/tests/client/idempotency.test.ts new file mode 100644 index 0000000..294ae6f --- /dev/null +++ b/tests/client/idempotency.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { newIdempotencyKey } from "../../src/client/idempotency.js"; + +describe("newIdempotencyKey", () => { + it("returns a UUIDv4-shaped string", () => { + const k = newIdempotencyKey(); + expect(k).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it("returns a different value each call", () => { + expect(newIdempotencyKey()).not.toBe(newIdempotencyKey()); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..32be4dd --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { parseEnv, ConfigError } from "../src/config.js"; + +describe("parseEnv", () => { + it("sandbox mode when VOIDMOB_SANDBOX=1 (no key needed)", () => { + const cfg = parseEnv({ VOIDMOB_SANDBOX: "1" }); + expect(cfg.sandbox).toBe(true); + expect(cfg.apiKey).toBeNull(); + }); + + it("live mode when VOIDMOB_API_KEY is set", () => { + const cfg = parseEnv({ VOIDMOB_API_KEY: "vmk_live_" + "a".repeat(32) }); + expect(cfg.sandbox).toBe(false); + expect(cfg.apiKey).toBe("vmk_live_" + "a".repeat(32)); + expect(cfg.baseUrl).toBe("https://dashboard.voidmob.com/api"); + }); + + it("accepts vmk_test_ keys", () => { + const cfg = parseEnv({ VOIDMOB_API_KEY: "vmk_test_" + "b".repeat(32) }); + expect(cfg.sandbox).toBe(false); + }); + + it("throws ConfigError when no mode selected", () => { + expect(() => parseEnv({})).toThrow(ConfigError); + }); + + it("throws ConfigError on bad key prefix", () => { + expect(() => parseEnv({ VOIDMOB_API_KEY: "sk_test_abc" })).toThrow(ConfigError); + }); + + it("throws ConfigError on too-short key", () => { + expect(() => parseEnv({ VOIDMOB_API_KEY: "vmk_live_short" })).toThrow(ConfigError); + }); + + it("VOIDMOB_BASE_URL overrides default", () => { + const cfg = parseEnv({ + VOIDMOB_API_KEY: "vmk_live_" + "a".repeat(32), + VOIDMOB_BASE_URL: "http://localhost:4000", + }); + expect(cfg.baseUrl).toBe("http://localhost:4000"); + }); + + it("VOIDMOB_DEBUG=1 enables debug", () => { + const cfg = parseEnv({ VOIDMOB_SANDBOX: "1", VOIDMOB_DEBUG: "1" }); + expect(cfg.debug).toBe(true); + }); + + it("error message points the user to the docs URL", () => { + expect(() => parseEnv({})).toThrowError(/dashboard\.voidmob\.com/); + }); + + it("empty VOIDMOB_API_KEY in live mode is treated as missing", () => { + expect(() => parseEnv({ VOIDMOB_API_KEY: "" })).toThrow(ConfigError); + }); +}); diff --git a/tests/fixtures/tools-list-v1.json b/tests/fixtures/tools-list-v1.json new file mode 100644 index 0000000..86606aa --- /dev/null +++ b/tests/fixtures/tools-list-v1.json @@ -0,0 +1,29 @@ +{ + "tools": [ + "cancel_rental", + "create_proxy_list", + "delete_proxy_list", + "get_account", + "get_esim_qr", + "get_esim_status", + "get_geo", + "get_proxy_status", + "get_rental", + "list_orders", + "list_proxy_lists", + "purchase_esim", + "purchase_proxy", + "re_rent_rental", + "regenerate_proxy_password", + "renew_proxy", + "rent_number", + "reuse_number", + "rotate_proxy_ip", + "search_esim_plans", + "search_proxies", + "search_sms_services", + "toggle_auto_renew", + "topup_esim", + "topup_proxy" + ] +} diff --git a/tests/mock-http.ts b/tests/mock-http.ts new file mode 100644 index 0000000..ddfdae1 --- /dev/null +++ b/tests/mock-http.ts @@ -0,0 +1,40 @@ +import type { HttpClient, HttpResponse } from "../src/client/http.js"; + +interface Expectation { + method: string; + path: string; + respond: HttpResponse; +} + +export interface MockHttpClient extends HttpClient { + expect(method: string, path: string, respond: HttpResponse): void; + history: Array<{ method: string; path: string; body?: unknown; headers: Record }>; +} + +export function createMockHttpClient(): MockHttpClient { + const queue: Expectation[] = []; + const history: MockHttpClient["history"] = []; + + const client: MockHttpClient = { + history, + expect(method, path, respond) { + queue.push({ method, path, respond }); + }, + async request(method, path, opts) { + const recordedHeaders: Record = { + ...((opts?.headers as Record) ?? {}), + }; + if (opts?.idempotencyKey) { + recordedHeaders["Idempotency-Key"] = opts.idempotencyKey; + } + history.push({ method, path, body: opts?.body, headers: recordedHeaders }); + const exp = queue.shift(); + if (!exp) throw new Error(`No expectation for ${method} ${path}`); + if (exp.method !== method || exp.path !== path) { + throw new Error(`Expected ${exp.method} ${exp.path}, got ${method} ${path}`); + } + return exp.respond; + }, + }; + return client; +} diff --git a/tests/sandbox.e2e.test.ts b/tests/sandbox.e2e.test.ts new file mode 100644 index 0000000..03f3eb8 --- /dev/null +++ b/tests/sandbox.e2e.test.ts @@ -0,0 +1,91 @@ +// Drives every sandbox tool against the in-memory mock client and asserts none +// return an error result. An error here means the mock's response shape no +// longer satisfies the live Zod schema / renderer - i.e. sandbox has drifted +// from live. Timing-gated transitions (verification code arrival, proxy going +// active) are exercised in their pre-transition state, which is still valid. +import { describe, it, expect } from "vitest"; +import type { ToolResult } from "../src/utils/render.js"; +import { createSandboxHttpClient } from "../src/sandbox/mock-http.js"; +import { getAccountHandler } from "../src/tools/account.js"; +import { + searchSmsServicesHandler, getRentalHandler, rentNumberHandler, + cancelRentalHandler, reuseNumberHandler, reRentRentalHandler, toggleAutoRenewHandler, +} from "../src/tools/sms.js"; +import { + searchEsimPlansHandler, purchaseEsimHandler, getEsimStatusHandler, + topupEsimHandler, getEsimQrHandler, +} from "../src/tools/esim.js"; +import { + searchProxiesHandler, purchaseProxyHandler, getProxyStatusHandler, rotateProxyIpHandler, + renewProxyHandler, topupProxyHandler, regenerateProxyPasswordHandler, + listProxyListsHandler, createProxyListHandler, deleteProxyListHandler, +} from "../src/tools/proxy.js"; +import { getGeoHandler } from "../src/tools/geo.js"; +import { listOrdersHandler } from "../src/tools/orders.js"; + +const http = createSandboxHttpClient(); +const sc = (r: ToolResult) => r.structuredContent as Record; +const okResult = (r: ToolResult) => { + expect(r.isError ?? false, (r.content[0] as { text?: string })?.text).toBe(false); + return r; +}; + +describe("sandbox e2e (every tool resolves against the mock)", () => { + it("account + catalogs + geo cascade", async () => { + okResult(await getAccountHandler(http)()); + okResult(await searchSmsServicesHandler(http)({})); + okResult(await searchEsimPlansHandler(http)({ country: "US" })); + okResult(await searchProxiesHandler(http)({ country: "US" })); + okResult(await getGeoHandler(http)({})); + okResult(await getGeoHandler(http)({ country: "US" })); + okResult(await getGeoHandler(http)({ country: "US", region: "California" })); + okResult(await getGeoHandler(http)({ country: "US", region: "California", city: "Los Angeles" })); + }); + + it("SMS verification lifecycle", async () => { + const ver = okResult(await rentNumberHandler(http)({ service_id: "svc_telegram", kind: "verification" })); + const id = sc(ver).verification.id as string; + expect(id.startsWith("ver_")).toBe(true); + okResult(await getRentalHandler(http)({ rental_id: id })); + okResult(await reuseNumberHandler(http)({ rental_id: id })); + okResult(await reuseNumberHandler(http)({ rental_id: id, paid: true })); + }); + + it("SMS long-term + dedicated lifecycle", async () => { + const ren = okResult(await rentNumberHandler(http)({ service_id: "svc_telegram", kind: "rental", duration: "7d" })); + const id = sc(ren).rental.id as string; + expect(id.startsWith("ren_")).toBe(true); + okResult(await toggleAutoRenewHandler(http)({ rental_id: id, auto_renew: true })); + okResult(await getRentalHandler(http)({ rental_id: id })); + okResult(await reRentRentalHandler(http)({ rental_id: id })); + okResult(await cancelRentalHandler(http)({ rental_id: id })); + okResult(await rentNumberHandler(http)({ service_id: "svc_dedicated_28d", kind: "dedicated" })); + }); + + it("eSIM lifecycle", async () => { + const esim = okResult(await purchaseEsimHandler(http)({ plan_id: "prod_us_5gb_30d" })); + const id = sc(esim).esim.id as string; + okResult(await getEsimStatusHandler(http)({ esim_id: id })); + okResult(await getEsimQrHandler(http)({ esim_id: id })); + okResult(await topupEsimHandler(http)({ esim_id: id })); + okResult(await topupEsimHandler(http)({ esim_id: id, topup_product_id: "prod_topup_5gb" })); + }); + + it("proxy lifecycle + lists", async () => { + const prx = okResult(await purchaseProxyHandler(http)({ plan_id: "pplan_us_5gb_30d" })); + const id = sc(prx).proxy.id as string; + okResult(await getProxyStatusHandler(http)({ proxy_id: id })); + okResult(await rotateProxyIpHandler(http)({ proxy_id: id })); + okResult(await topupProxyHandler(http)({ proxy_id: id, additional_gb: 5 })); + okResult(await renewProxyHandler(http)({ proxy_id: id })); + okResult(await regenerateProxyPasswordHandler(http)({ proxy_id: id })); + const list = okResult(await createProxyListHandler(http)({ proxy_id: id, name: "la", country: "us", city: "Los Angeles" })); + const listId = sc(list).list.id as string; + okResult(await listProxyListsHandler(http)({ proxy_id: id })); + okResult(await deleteProxyListHandler(http)({ proxy_id: id, list_id: listId })); + }); + + it("list_orders aggregates across kinds", async () => { + okResult(await listOrdersHandler(http)({})); + }); +}); diff --git a/tests/sandbox.smoke.test.ts b/tests/sandbox.smoke.test.ts new file mode 100644 index 0000000..f255078 --- /dev/null +++ b/tests/sandbox.smoke.test.ts @@ -0,0 +1,34 @@ +// tests/sandbox.smoke.test.ts +import { describe, it, expect } from "vitest"; +import { buildSandboxServer } from "../src/modes/sandbox.js"; +import { buildLiveServer } from "../src/modes/live.js"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function toolNames(server: ReturnType): string[] { + // @ts-expect-error - reach into internal map for the registered tool set + return Object.keys(server._registeredTools).sort(); +} + +const liveServer = () => + buildLiveServer({ sandbox: false, apiKey: "vmk_live_" + "a".repeat(32), baseUrl: "https://x", debug: false }); + +describe("smoke (live)", () => { + it("registers exactly 25 live tools matching the checked-in fixture", () => { + const names = toolNames(liveServer()); + expect(names.length).toBe(25); + + const fixturePath = join(__dirname, "fixtures/tools-list-v1.json"); + const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as { tools: string[] }; + expect(names).toEqual([...fixture.tools].sort()); + }); +}); + +describe("smoke (sandbox)", () => { + it("exposes the EXACT same tool set as live (cannot drift)", () => { + expect(toolNames(buildSandboxServer())).toEqual(toolNames(liveServer())); + }); +}); diff --git a/tests/tools/account.test.ts b/tests/tools/account.test.ts new file mode 100644 index 0000000..5ff276f --- /dev/null +++ b/tests/tools/account.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { getAccountHandler } from "../../src/tools/account.js"; +import { createMockHttpClient } from "../mock-http.js"; + +describe("get_account", () => { + it("calls GET /v1/me and renders balance + rate limits", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/me", { + status: 200, + body: { + success: true, + data: { + id: "usr_abc", + balance: { amount_cents: 1250, currency: "USD", formatted: "$12.50" }, + rate_limits: { + account: { limit: 60, window_seconds: 60 }, + verifications: { limit: 30, window_seconds: 60 }, + }, + created_at: "2026-01-01T00:00:00Z", + }, + }, + headers: new Headers(), + }); + + const handler = getAccountHandler(http); + const res = await handler(); + expect(res.isError).toBeFalsy(); + const textBlock = res.content[0]; + if (textBlock.type !== "text") throw new Error("expected text block"); + expect(textBlock.text).toContain("$12.50"); + expect(res.structuredContent?.account).toMatchObject({ id: "usr_abc" }); + }); + + it("maps UNAUTHENTICATED to docs URL", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/me", { + status: 401, + body: { + success: false, + error: { + code: "UNAUTHENTICATED", + message: "Authentication required.", + request_id: "req_x", + docs_url: "", + }, + }, + headers: new Headers(), + }); + const handler = getAccountHandler(http); + const res = await handler(); + expect(res.isError).toBe(true); + const textBlock = res.content[0]; + if (textBlock.type !== "text") throw new Error("expected text block"); + expect(textBlock.text).toContain("developers/api-keys"); + expect(textBlock.text).toContain("req_x"); + }); +}); diff --git a/tests/tools/esim.test.ts b/tests/tools/esim.test.ts new file mode 100644 index 0000000..1c1e237 --- /dev/null +++ b/tests/tools/esim.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect } from "vitest"; +import { + searchEsimPlansHandler, + purchaseEsimHandler, + getEsimStatusHandler, + topupEsimHandler, + getEsimQrHandler, +} from "../../src/tools/esim.js"; +import { createMockHttpClient } from "../mock-http.js"; + +// ── Fixture builders ──────────────────────────────────────────────────────── + +function productFixture(overrides: Partial> = {}) { + return { + id: "esim_product_jp7d", + title: "Japan 5GB / 7 days", + countries: ["JP"], + region: "Asia", + country_count: 1, + routing_location: "JP", + data_limit_gb: 5, + data_unlimited: false, + validity_days: 7, + features: { + has_5g: true, + has_hotspot: true, + has_calls: false, + has_sms: false, + supports_topup: true, + }, + price_cents: 999, + currency: "USD", + ...overrides, + }; +} + +function esimFixture(overrides: Partial> = {}) { + return { + id: "esim_abc", + status: "completed", + product_id: "esim_product_jp7d", + is_topup: false, + parent_order_id: null, + iccid: "8901123412345678901", + activation_code: "LPA:1$smdp.voidmob.com$ABC123", + qr_code_url: "https://dashboard.voidmob.com/api/v1/esims/esim_abc/qr.png", + smdp_address: "smdp.voidmob.com", + data_limit_gb: 5, + data_unlimited: false, + validity_days: 7, + countries: ["JP"], + routing_location: "JP", + charged_price_cents: 999, + currency: "USD", + created_at: "2026-05-21T00:00:00Z", + completed_at: "2026-05-21T00:00:00Z", + expires_at: "2026-05-28T00:00:00Z", + ...overrides, + }; +} + +function usageFixture(overrides: Partial> = {}) { + return { + esim_id: "esim_abc", + esim_status: "completed", + packages: [ + { + name: "Plan A", + total_mb: 5120, + total_gb: 5, + used_mb: 250, + used_gb: 0.24, + remaining_mb: 4870, + remaining_gb: 4.8, + percent_used: 4.9, + activation_date: "2026-05-21T00:00:00Z", + expiration_date: "2026-05-28T00:00:00Z", + }, + ], + ...overrides, + }; +} + +// ── search_esim_plans ─────────────────────────────────────────────────────── + +describe("search_esim_plans", () => { + it("composes query string with all filters and renders a list", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products?country=JP&min_data_gb=5&has_5g=true&limit=20", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + products: [productFixture()], + next_cursor: null, + }, + }, + }); + const res = await searchEsimPlansHandler(http)({ + country: "JP", + min_data_gb: 5, + has_5g: true, + }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("Japan 5GB / 7 days"); + expect(t.text).toContain("JP"); + expect(t.text).toContain("$9.99"); + const plans = res.structuredContent?.esim_plans as Array>; + expect(plans).toHaveLength(1); + // Full plan shape returned — no separate get_esim_plan_details tool needed + expect(plans[0]).toMatchObject({ + region: "Asia", + country_count: 1, + routing_location: "JP", + data_limit_gb: 5, + features: { has_5g: true, has_hotspot: true, supports_topup: true }, + }); + }); + + it("returns toolError when no plans match", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products?country=XX&limit=20", { + status: 200, + headers: new Headers(), + body: { success: true, data: { products: [], next_cursor: null } }, + }); + const res = await searchEsimPlansHandler(http)({ country: "XX" }); + expect(res.isError).toBe(true); + }); + + it("surfaces upstream error with request_id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products?limit=20", { + status: 500, + headers: new Headers(), + body: { + success: false, + error: { code: "INTERNAL_ERROR", message: "boom", request_id: "req_esim_err", docs_url: "" }, + }, + }); + const res = await searchEsimPlansHandler(http)({}); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("req_esim_err"); + }); +}); + +// ── purchase_esim ─────────────────────────────────────────────────────────── + +describe("purchase_esim", () => { + it("quote-then-commit: GET product, POST /v1/esims with tied max_price_cents and idempotency", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products/esim_product_jp7d", { + status: 200, + headers: new Headers(), + body: { success: true, data: { product: productFixture() } }, + }); + http.expect("POST", "/v1/esims", { + status: 201, + headers: new Headers(), + body: { success: true, data: { esim: esimFixture() } }, + }); + const res = await purchaseEsimHandler(http)({ plan_id: "esim_product_jp7d" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(2); + expect(http.history[1].method).toBe("POST"); + expect(http.history[1].body).toMatchObject({ + product_id: "esim_product_jp7d", + max_price_cents: 999, + }); + expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" }); + }); + + it("maps PRICE_OVER_CAP from commit step with request_id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products/esim_product_jp7d", { + status: 200, + headers: new Headers(), + body: { success: true, data: { product: productFixture() } }, + }); + http.expect("POST", "/v1/esims", { + status: 409, + headers: new Headers(), + body: { + success: false, + error: { + code: "PRICE_OVER_CAP", + message: "...", + request_id: "req_esim_cap", + details: { max_price_cents: 999, available_price_cents: 1099 }, + docs_url: "", + }, + }, + }); + const res = await purchaseEsimHandler(http)({ plan_id: "esim_product_jp7d" }); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("$9.99"); + expect(t.text).toContain("$10.99"); + expect(t.text).toContain("req_esim_cap"); + }); +}); + +// ── get_esim_status ───────────────────────────────────────────────────────── + +describe("get_esim_status", () => { + it("fetches core + usage in parallel and merges into structuredContent", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esims/esim_abc", { + status: 200, + headers: new Headers(), + body: { success: true, data: { esim: esimFixture() } }, + }); + http.expect("GET", "/v1/esims/esim_abc/usage", { + status: 200, + headers: new Headers(), + body: { success: true, data: { usage: usageFixture() } }, + }); + const res = await getEsimStatusHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBeFalsy(); + expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" }); + expect(res.structuredContent?.usage).toMatchObject({ + esim_id: "esim_abc", + esim_status: "completed", + }); + expect((res.structuredContent?.usage as { packages: unknown[] }).packages).toHaveLength(1); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("250 MB"); + expect(t.text).toContain("5120 MB"); + }); + + it("USAGE_UNAVAILABLE: degrades gracefully with usage=null", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esims/esim_abc", { + status: 200, + headers: new Headers(), + body: { success: true, data: { esim: esimFixture() } }, + }); + http.expect("GET", "/v1/esims/esim_abc/usage", { + status: 503, + headers: new Headers(), + body: { + success: false, + error: { code: "USAGE_UNAVAILABLE", message: "...", request_id: "req_u", docs_url: "" }, + }, + }); + const res = await getEsimStatusHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBeFalsy(); + expect(res.structuredContent?.esim).toMatchObject({ id: "esim_abc" }); + expect(res.structuredContent?.usage).toBeNull(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("not yet available"); + }); +}); + +// ── topup_esim ────────────────────────────────────────────────────────────── + +describe("topup_esim", () => { + it("browse: no topup_product_id → GET /v1/esims/:id/topups, renders list", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esims/esim_abc/topups", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + supports_topup: true, + topups: [ + productFixture({ id: "esim_topup_jp_3gb", title: "Japan +3GB", price_cents: 599, data_limit_gb: 3 }), + productFixture({ id: "esim_topup_jp_10gb", title: "Japan +10GB", price_cents: 1499, data_limit_gb: 10 }), + ], + }, + }, + }); + const res = await topupEsimHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(1); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("Japan +3GB"); + expect(t.text).toContain("Japan +10GB"); + expect((res.structuredContent?.topups as unknown[])).toHaveLength(2); + }); + + it("browse: supports_topup=false → toolError", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esims/esim_abc/topups", { + status: 200, + headers: new Headers(), + body: { success: true, data: { supports_topup: false, topups: [] } }, + }); + const res = await topupEsimHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBe(true); + }); + + it("purchase: topup_product_id supplied → quote then POST /v1/esims/:id/topups", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/esim_products/esim_topup_jp_3gb", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { product: productFixture({ id: "esim_topup_jp_3gb", title: "Japan +3GB", price_cents: 599, data_limit_gb: 3 }) }, + }, + }); + http.expect("POST", "/v1/esims/esim_abc/topups", { + status: 201, + headers: new Headers(), + body: { + success: true, + data: { + esim: esimFixture({ + id: "esim_topup_xyz", + product_id: "esim_topup_jp_3gb", + data_limit_gb: null, + validity_days: 7, + charged_price_cents: 599, + is_topup: true, + parent_order_id: "esim_abc", + iccid: null, + activation_code: null, + qr_code_url: null, + smdp_address: null, + }), + }, + }, + }); + const res = await topupEsimHandler(http)({ + esim_id: "esim_abc", + topup_product_id: "esim_topup_jp_3gb", + }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(2); + expect(http.history[1].method).toBe("POST"); + expect(http.history[1].path).toBe("/v1/esims/esim_abc/topups"); + expect(http.history[1].body).toMatchObject({ + product_id: "esim_topup_jp_3gb", + max_price_cents: 599, + }); + expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.esim).toMatchObject({ id: "esim_topup_xyz", is_topup: true }); + }); +}); + +// ── get_esim_qr ───────────────────────────────────────────────────────────── + +describe("get_esim_qr", () => { + it("returns content with both text + image blocks; structuredContent has esim_id", async () => { + const http = createMockHttpClient(); + // Minimal PNG magic bytes + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + http.expect("GET", "/v1/esims/esim_abc/qr.png", { + status: 200, + headers: new Headers(), + binary: png, + }); + const res = await getEsimQrHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBeFalsy(); + expect(res.content).toHaveLength(2); + expect(res.content[0].type).toBe("text"); + const img = res.content[1]; + if (img.type !== "image") throw new Error("expected image block"); + expect(img.mimeType).toBe("image/png"); + expect(img.data).toBe(png.toString("base64")); + expect(res.structuredContent?.esim_id).toBe("esim_abc"); + }); + + it("returns toolError when server returns no binary payload", async () => { + const http = createMockHttpClient(); + // Edge case: 2xx with neither binary nor a parseable body (server bug) + http.expect("GET", "/v1/esims/esim_abc/qr.png", { + status: 200, + headers: new Headers(), + }); + const res = await getEsimQrHandler(http)({ esim_id: "esim_abc" }); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toMatch(/no binary payload/i); + }); +}); diff --git a/tests/tools/geo.test.ts b/tests/tools/geo.test.ts new file mode 100644 index 0000000..33dad3a --- /dev/null +++ b/tests/tools/geo.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { getGeoHandler } from "../../src/tools/geo.js"; +import { createMockHttpClient } from "../mock-http.js"; + +// ── Fixture builders ──────────────────────────────────────────────────────── + +function countryFixture(overrides: Partial> = {}) { + return { code: "US", name: "United States", available_nodes: 1234, ...overrides }; +} + +function regionFixture(overrides: Partial> = {}) { + return { name: "California", available_nodes: 250, ...overrides }; +} + +function cityFixture(overrides: Partial> = {}) { + return { name: "Los Angeles", available_nodes: 80, ...overrides }; +} + +function ispFixture(overrides: Partial> = {}) { + return { name: "Comcast", available_nodes: 12, ...overrides }; +} + +// ── get_geo ──────────────────────────────────────────────────────────────── + +describe("get_geo", () => { + it("with no params hits /v1/geo and renders countries", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/geo", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { countries: [countryFixture(), countryFixture({ code: "GB", name: "United Kingdom", available_nodes: 500 })] }, + }, + }); + const res = await getGeoHandler(http)({}); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("2 countries"); + expect(t.text).toContain("United States"); + expect(t.text).toContain("United Kingdom"); + expect(t.text).toContain("(US)"); + const countries = res.structuredContent?.countries as Array>; + expect(countries).toHaveLength(2); + expect(countries[0]).toMatchObject({ code: "US", name: "United States", available_nodes: 1234 }); + }); + + it("with country=US hits /v1/geo?country=US and renders regions", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/geo?country=US", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { regions: [regionFixture()] }, + }, + }); + const res = await getGeoHandler(http)({ country: "US" }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("1 regions"); + expect(t.text).toContain("California"); + const regions = res.structuredContent?.regions as Array>; + expect(regions).toHaveLength(1); + expect(regions[0]).toMatchObject({ name: "California", available_nodes: 250 }); + }); + + it("with country + region hits cascading geo and renders cities", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/geo?country=US®ion=California", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { cities: [cityFixture(), cityFixture({ name: "San Francisco", available_nodes: 45 })] }, + }, + }); + const res = await getGeoHandler(http)({ country: "US", region: "California" }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("2 cities"); + expect(t.text).toContain("Los Angeles"); + expect(t.text).toContain("San Francisco"); + const cities = res.structuredContent?.cities as Array>; + expect(cities).toHaveLength(2); + }); + + it("with country + region + city hits cascading geo and renders ISPs", async () => { + const http = createMockHttpClient(); + // URLSearchParams encodes spaces as '+' + http.expect("GET", "/v1/geo?country=US®ion=California&city=Los+Angeles", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { isps: [ispFixture(), ispFixture({ name: "AT&T", available_nodes: 7 })] }, + }, + }); + const res = await getGeoHandler(http)({ country: "US", region: "California", city: "Los Angeles" }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("2 ISP(s)"); + expect(t.text).toContain("Comcast"); + expect(t.text).toContain("AT&T"); + const isps = res.structuredContent?.isps as Array>; + expect(isps).toHaveLength(2); + expect(isps[0]).toMatchObject({ name: "Comcast", available_nodes: 12 }); + }); + + it("propagates request_id on 4xx error", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/geo?country=ZZ", { + status: 400, + headers: new Headers(), + body: { + success: false, + error: { + code: "INVALID_REQUEST", + message: "Unknown country code.", + request_id: "req_geo_4xx", + }, + }, + }); + const res = await getGeoHandler(http)({ country: "ZZ" }); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("req_geo_4xx"); + expect(t.text).toContain("Unknown country code"); + }); +}); diff --git a/tests/tools/orders.test.ts b/tests/tools/orders.test.ts new file mode 100644 index 0000000..8d81249 --- /dev/null +++ b/tests/tools/orders.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect } from "vitest"; +import { listOrdersHandler } from "../../src/tools/orders.js"; +import { createMockHttpClient } from "../mock-http.js"; + +// ── Fixture builders ──────────────────────────────────────────────────────── + +function rentalFixture(overrides: Partial> = {}) { + return { + id: "ren_old", + display_id: "LTR123", + status: "active", + phone_number: "+14155550123", + service_id: "svc_tg", + service_name: "Telegram", + country: "us", + duration: "7D", + rental_type: "rental", + charged_price_cents: 500, + auto_renew: false, + next_renewal_price_cents: 500, + re_rent_available: false, + re_rent_price_cents: null, + re_rent_blocked_at: null, + created_at: "2026-05-01T00:00:00Z", + paid_until: "2026-05-28T00:00:00Z", + expires_at: "2026-05-28T00:00:00Z", + can_cancel: false, + cancel_window_expires_at: null, + messages: [], + ...overrides, + }; +} + +function esimFixture(overrides: Partial> = {}) { + return { + id: "esim_mid", + status: "completed", + product_id: "prod_jp7d", + is_topup: false, + parent_order_id: null, + iccid: "8901123412345678901", + activation_code: "LPA:1$smdp.voidmob.com$ABC123", + qr_code_url: "https://dashboard.voidmob.com/api/v1/esims/esim_mid/qr.png", + smdp_address: "smdp.voidmob.com", + data_limit_gb: 5, + data_unlimited: false, + validity_days: 7, + countries: ["JP"], + routing_location: "JP", + charged_price_cents: 999, + currency: "USD", + created_at: "2026-05-10T00:00:00Z", + completed_at: "2026-05-10T00:00:00Z", + expires_at: "2026-05-28T00:00:00Z", + ...overrides, + }; +} + +function gatewayFixture() { + return { + host: "us.proxy.voidmob.com", + port: 10000, + protocol: "http", + username: "vm_abc", + password: "p4ss", + }; +} + +function proxyFixture(overrides: Partial> = {}) { + return { + id: "px_new", + status: "active", + plan_id: "proxy_plan_us_shared_5gb", + data_gb_total: 5, + data_bytes_used: 0, + charged_price_cents: 1499, + expires_at: "2026-06-20T00:00:00Z", + gateway: gatewayFixture(), + lists: [], + rotation_url: null, + created_at: "2026-05-20T00:00:00Z", + ...overrides, + }; +} + +// ── list_orders ───────────────────────────────────────────────────────────── + +describe("list_orders", () => { + it("with no kind filter fans out to all 3 endpoints, merges, sorts desc by created_at", async () => { + const http = createMockHttpClient(); + // FIFO: handler enqueues sms → esim → proxy in order + http.expect("GET", "/v1/rentals", { + status: 200, + headers: new Headers(), + body: { success: true, data: [rentalFixture()] }, + }); + http.expect("GET", "/v1/esims", { + status: 200, + headers: new Headers(), + body: { success: true, data: { esims: [esimFixture()] } }, + }); + http.expect("GET", "/v1/proxies", { + status: 200, + headers: new Headers(), + body: { success: true, data: { proxies: [proxyFixture()] } }, + }); + + const res = await listOrdersHandler(http)({}); + expect(res.isError).toBeFalsy(); + + // All three GETs occurred + expect(http.history.map((h) => h.path)).toEqual([ + "/v1/rentals", + "/v1/esims", + "/v1/proxies", + ]); + + const orders = res.structuredContent?.orders as Array>; + expect(orders).toHaveLength(3); + // Sorted by created_at desc: proxy(2026-05-20) > esim(2026-05-10) > rental(2026-05-01) + expect(orders[0]).toMatchObject({ kind: "proxy", id: "px_new" }); + expect(orders[1]).toMatchObject({ kind: "esim", id: "esim_mid" }); + expect(orders[2]).toMatchObject({ kind: "sms", id: "ren_old" }); + + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("3 order(s)"); + expect(t.text).toContain("SMS"); + expect(t.text).toContain("ESIM"); + expect(t.text).toContain("PROXY"); + }); + + it("with kind='sms' only fetches /v1/rentals", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/rentals", { + status: 200, + headers: new Headers(), + body: { success: true, data: [rentalFixture()] }, + }); + + const res = await listOrdersHandler(http)({ kind: "sms" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(1); + expect(http.history[0].path).toBe("/v1/rentals"); + const orders = res.structuredContent?.orders as Array>; + expect(orders).toHaveLength(1); + expect(orders[0]).toMatchObject({ kind: "sms", id: "ren_old" }); + }); + + it("surfaces results from successful fan-out branches when one fails, with partial warning", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/rentals", { + status: 200, + headers: new Headers(), + body: { success: true, data: [rentalFixture()] }, + }); + http.expect("GET", "/v1/esims", { + status: 200, + headers: new Headers(), + body: { success: true, data: { esims: [esimFixture()] } }, + }); + // Proxies endpoint fails — callApi will throw on success:false envelope + http.expect("GET", "/v1/proxies", { + status: 500, + headers: new Headers(), + body: { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "Proxy service down.", + request_id: "req_px_500", + }, + }, + }); + + const res = await listOrdersHandler(http)({}); + expect(res.isError).toBeFalsy(); + const orders = res.structuredContent?.orders as Array>; + expect(orders).toHaveLength(2); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("partial:"); + }); + + it("with limit=2 truncates output even when more rows exist", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/rentals", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: [ + rentalFixture({ id: "ren_1", created_at: "2026-05-01T00:00:00Z" }), + rentalFixture({ id: "ren_2", created_at: "2026-05-02T00:00:00Z" }), + rentalFixture({ id: "ren_3", created_at: "2026-05-03T00:00:00Z" }), + rentalFixture({ id: "ren_4", created_at: "2026-05-04T00:00:00Z" }), + ], + }, + }); + + const res = await listOrdersHandler(http)({ kind: "sms", limit: 2 }); + expect(res.isError).toBeFalsy(); + const orders = res.structuredContent?.orders as Array>; + expect(orders).toHaveLength(2); + // Newest first + expect(orders[0]).toMatchObject({ id: "ren_4" }); + expect(orders[1]).toMatchObject({ id: "ren_3" }); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("4 order(s)"); + expect(t.text).toContain("(showing 2)"); + }); + + it("surfaces partial-failure warnings (not a misleading 'No orders found.') when all 3 branches fail", async () => { + const http = createMockHttpClient(); + const fail = { + status: 500, + headers: new Headers(), + body: { + success: false, + error: { code: "INTERNAL_ERROR", message: "boom", request_id: "req_x" }, + }, + }; + http.expect("GET", "/v1/rentals", fail); + http.expect("GET", "/v1/esims", fail); + http.expect("GET", "/v1/proxies", fail); + + const res = await listOrdersHandler(http)({}); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + // When every fan-out call fails, the empty result is a hidden error, not an + // empty account: surface the partial failures instead of "No orders found." + expect(t.text).toContain("Could not load orders"); + expect(t.text).toContain("partial:"); + expect(t.text).not.toContain("No orders found."); + }); + + it("returns 'No orders found.' only when the account is genuinely empty (no warnings)", async () => { + const http = createMockHttpClient(); + const empty = { status: 200, headers: new Headers(), body: { success: true, data: [] as unknown[] } }; + http.expect("GET", "/v1/rentals", empty); + http.expect("GET", "/v1/esims", { status: 200, headers: new Headers(), body: { success: true, data: { esims: [] } } }); + http.expect("GET", "/v1/proxies", { status: 200, headers: new Headers(), body: { success: true, data: { proxies: [] } } }); + + const res = await listOrdersHandler(http)({}); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("No orders found."); + }); +}); diff --git a/tests/tools/proxy.test.ts b/tests/tools/proxy.test.ts new file mode 100644 index 0000000..b05b69d --- /dev/null +++ b/tests/tools/proxy.test.ts @@ -0,0 +1,594 @@ +import { describe, it, expect } from "vitest"; +import { + searchProxiesHandler, + purchaseProxyHandler, + getProxyStatusHandler, + rotateProxyIpHandler, + renewProxyHandler, + topupProxyHandler, + regenerateProxyPasswordHandler, + listProxyListsHandler, + createProxyListHandler, + deleteProxyListHandler, +} from "../../src/tools/proxy.js"; +import { createMockHttpClient } from "../mock-http.js"; + +// ── Fixture builders ──────────────────────────────────────────────────────── + +function planFixture(overrides: Partial> = {}) { + return { + id: "proxy_plan_us_shared_5gb", + name: "US Shared 5GB / 30d", + type: "shared", + country: "US", + data_gb: 5, + duration_days: 30, + quoted_price_cents: 1499, + ...overrides, + }; +} + +function gatewayFixture(overrides: Partial> = {}) { + return { + host: "us.proxy.voidmob.com", + port: 10000, + protocol: "http", + username: "vm_abc123", + password: "p4ssw0rd", + ...overrides, + }; +} + +function proxyResp(id: string, overrides: Partial> = {}) { + return { + id, + status: "active", + plan_id: "proxy_plan_us_shared_5gb", + type: "shared", + country: "US", + data_gb_total: 5, + data_bytes_used: 0, + charged_price_cents: 1499, + expires_at: "2026-06-20T00:00:00Z", + gateway: gatewayFixture(), + lists: [], + created_at: "2026-05-21T00:00:00Z", + ...overrides, + }; +} + +// ── search_proxies ────────────────────────────────────────────────────────── + +describe("search_proxies", () => { + it("composes query string with all filters and renders a list", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxy_plans?country=US&min_gb=5", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { plans: [planFixture()] }, + }, + }); + const res = await searchProxiesHandler(http)({ + country: "US", + min_data_gb: 5, + }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("US Shared 5GB / 30d"); + expect(t.text).toContain("$14.99"); + expect(t.text).toContain("US"); + const plans = res.structuredContent?.proxy_plans as Array>; + expect(plans).toHaveLength(1); + expect(plans[0]).toMatchObject({ + id: "proxy_plan_us_shared_5gb", + type: "shared", + country: "US", + quoted_price_cents: 1499, + }); + }); + + it("returns toolError when no plans match", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxy_plans?country=ZZ", { + status: 200, + headers: new Headers(), + body: { success: true, data: { plans: [] } }, + }); + const res = await searchProxiesHandler(http)({ country: "ZZ" }); + expect(res.isError).toBe(true); + }); + + it("surfaces upstream error with request_id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxy_plans", { + status: 500, + headers: new Headers(), + body: { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "boom", + request_id: "req_proxy_err", + docs_url: "", + }, + }, + }); + const res = await searchProxiesHandler(http)({}); + expect(res.isError).toBe(true); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("req_proxy_err"); + }); +}); + +// ── purchase_proxy ────────────────────────────────────────────────────────── + +describe("purchase_proxy", () => { + it("quote-then-commit: GET plans, find plan, POST /v1/proxies with tied max_price_cents and idempotency", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxy_plans", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { plans: [planFixture()] }, + }, + }); + http.expect("POST", "/v1/proxies", { + status: 202, + headers: new Headers(), + body: { + success: true, + data: { + proxy: proxyResp("proxy_xyz", { status: "provisioning", gateway: null }), + }, + }, + }); + const res = await purchaseProxyHandler(http)({ plan_id: "proxy_plan_us_shared_5gb" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(2); + expect(http.history[1].method).toBe("POST"); + expect(http.history[1].path).toBe("/v1/proxies"); + expect(http.history[1].body).toMatchObject({ + plan_id: "proxy_plan_us_shared_5gb", + max_price_cents: 1499, + }); + expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.proxy).toMatchObject({ + id: "proxy_xyz", + status: "provisioning", + }); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("provisioning"); + expect(t.text).toContain("get_proxy_status"); + }); + + it("plan not found → toolError without commit attempt", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxy_plans", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { plans: [planFixture()] }, + }, + }); + const res = await purchaseProxyHandler(http)({ plan_id: "proxy_plan_does_not_exist" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(1); // No POST attempt + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("proxy_plan_does_not_exist"); + expect(t.text).toContain("search_proxies"); + }); +}); + +// ── get_proxy_status ──────────────────────────────────────────────────────── + +describe("get_proxy_status", () => { + it("happy path: 3 parallel calls (proxy GET, usage GET, nolist POST), merged response", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/proxy_xyz", { + status: 200, + headers: new Headers(), + // Core GET has gateway null until nolist provisions it. + body: { + success: true, + data: { proxy: proxyResp("proxy_xyz", { data_bytes_used: 1073741824, gateway: null }) }, + }, + }); + http.expect("GET", "/v1/proxies/proxy_xyz/usage", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { usage: { daily_bytes: 0, weekly_bytes: 0, monthly_bytes: 0, total_bytes: 1073741824, total_gb_allocated: 5, remaining_bytes: 4 } }, + }, + }); + // nolist is an idempotent get-or-create POST that returns the proxy with + // gateway populated. + http.expect("POST", "/v1/proxies/proxy_xyz/nolist_credentials", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { proxy: proxyResp("proxy_xyz", { data_bytes_used: 1073741824 }) }, + }, + }); + const res = await getProxyStatusHandler(http)({ proxy_id: "proxy_xyz" }); + expect(res.isError).toBeFalsy(); + expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" }); + expect(res.structuredContent?.usage).toMatchObject({ total_bytes: 1073741824 }); + expect(res.structuredContent?.nolist_credentials).toMatchObject({ username: "vm_abc123" }); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("proxy_xyz"); + expect(t.text).toContain("us.proxy.voidmob.com"); + expect(t.text).toContain("p4ssw0rd"); + }); + + it("partial failure: usage and nolist return 503 → still succeeds with those fields null", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/proxy_xyz", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { proxy: proxyResp("proxy_xyz", { gateway: null, status: "provisioning" }) }, + }, + }); + http.expect("GET", "/v1/proxies/proxy_xyz/usage", { + status: 503, + headers: new Headers(), + body: { + success: false, + error: { + code: "USAGE_UNAVAILABLE", + message: "usage not ready", + request_id: "req_usage_503", + docs_url: "", + }, + }, + }); + http.expect("POST", "/v1/proxies/proxy_xyz/nolist_credentials", { + status: 409, + headers: new Headers(), + body: { + success: false, + error: { + code: "PROXY_NOT_READY", + message: "not provisioned yet", + request_id: "req_nolist_409", + docs_url: "", + }, + }, + }); + const res = await getProxyStatusHandler(http)({ proxy_id: "proxy_xyz" }); + expect(res.isError).toBeFalsy(); + expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" }); + expect(res.structuredContent?.usage).toBeNull(); + // gateway is null on the core GET when nolist isn't provisioned yet. + expect(res.structuredContent?.nolist_credentials).toBeNull(); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("not yet provisioned"); + }); +}); + +// ── rotate_proxy_ip ───────────────────────────────────────────────────────── + +describe("rotate_proxy_ip", () => { + it("happy path with idempotency, surfaces proxy_id/rotated_at/current_ip", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/proxies/PRX-abc/rotate_ip", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + proxy_id: "PRX-abc", + rotated_at: "2026-01-01T00:00:00Z", + current_ip: "1.2.3.4", + }, + }, + }); + const res = await rotateProxyIpHandler(http)({ proxy_id: "PRX-abc" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(1); + expect(http.history[0].method).toBe("POST"); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("PRX-abc"); + expect(t.text).toContain("2026-01-01T00:00:00Z"); + expect(t.text).toContain("1.2.3.4"); + expect(res.structuredContent?.proxy_id).toBe("PRX-abc"); + expect(res.structuredContent?.rotated_at).toBe("2026-01-01T00:00:00Z"); + expect(res.structuredContent?.current_ip).toBe("1.2.3.4"); + }); +}); + +// ── renew_proxy ───────────────────────────────────────────────────────────── + +describe("renew_proxy", () => { + it("happy path: fetch core, fetch plans, POST renew with tied max_price_cents + idempotency", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/proxy_xyz", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { proxy: proxyResp("proxy_xyz") }, + }, + }); + http.expect("GET", "/v1/proxy_plans", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { plans: [planFixture()] }, + }, + }); + http.expect("POST", "/v1/proxies/proxy_xyz/renew", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + proxy: proxyResp("proxy_xyz", { expires_at: "2026-07-20T00:00:00Z" }), + }, + }, + }); + const res = await renewProxyHandler(http)({ proxy_id: "proxy_xyz" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(3); + expect(http.history[2].method).toBe("POST"); + expect(http.history[2].path).toBe("/v1/proxies/proxy_xyz/renew"); + expect(http.history[2].body).toMatchObject({ max_price_cents: 1499 }); + expect(http.history[2].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.proxy).toMatchObject({ + id: "proxy_xyz", + expires_at: "2026-07-20T00:00:00Z", + }); + }); + + it("proxy has null plan_id → toolError, no plan fetch or POST", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/proxy_legacy", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { proxy: proxyResp("proxy_legacy", { plan_id: null }) }, + }, + }); + const res = await renewProxyHandler(http)({ proxy_id: "proxy_legacy" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(1); // Stopped before plans + renew + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("proxy_legacy"); + expect(t.text).toContain("no plan_id"); + }); +}); + +// ── topup_proxy ───────────────────────────────────────────────────────────── + +describe("topup_proxy", () => { + it("quote-then-commit: GET proxy + plans, POST topup with additional_gb + tied max_price_cents + idempotency", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/proxy_xyz", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { proxy: proxyResp("proxy_xyz") }, + }, + }); + http.expect("GET", "/v1/proxy_plans", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { plans: [planFixture()] }, + }, + }); + http.expect("POST", "/v1/proxies/proxy_xyz/topup", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + proxy: proxyResp("proxy_xyz", { data_gb_total: 10 }), + charged_price_cents: 1499, + }, + }, + }); + const res = await topupProxyHandler(http)({ proxy_id: "proxy_xyz", additional_gb: 5 }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(3); + expect(http.history[2].method).toBe("POST"); + expect(http.history[2].path).toBe("/v1/proxies/proxy_xyz/topup"); + // Plan: quoted=1499c for 5GB → perGb=299.8 → 5GB topup = round(1499) = 1499 + expect(http.history[2].body).toMatchObject({ additional_gb: 5, max_price_cents: 1499 }); + expect(http.history[2].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("5 GB"); + expect(t.text).toContain("$14.99"); + expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz", data_gb_total: 10 }); + }); +}); + +// ── regenerate_proxy_password ─────────────────────────────────────────────── + +describe("regenerate_proxy_password", () => { + it("happy path + new password surfaced in text", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/proxies/proxy_xyz/regenerate_password", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + proxy: proxyResp("proxy_xyz", { + gateway: gatewayFixture({ password: "n3wP4ssw0rd" }), + }), + }, + }, + }); + const res = await regenerateProxyPasswordHandler(http)({ proxy_id: "proxy_xyz" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(1); + expect(http.history[0].method).toBe("POST"); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + const t = res.content[0]; + if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("n3wP4ssw0rd"); + expect(t.text).toContain("proxy_xyz"); + expect(res.structuredContent?.proxy).toMatchObject({ id: "proxy_xyz" }); + }); +}); + +// ── list_proxy_lists ──────────────────────────────────────────────────────── + +describe("list_proxy_lists", () => { + it("reads lists from GET /v1/proxies/:id (no list collection endpoint)", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/prx_abc", { + status: 200, headers: new Headers(), + body: { success: true, data: { proxy: proxyResp("prx_abc", { lists: [{ + id: "lst_1", proxy_id: "prx_abc", name: "Default", + country: null, countries: ["us", "ca"], region: null, city: null, isp: null, zip: null, + rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port", + credentials: { host: "proxy.voidmob.com", port: 10000, protocol: "http", username: "u", password: "p" }, + entries: ["u:p@proxy.voidmob.com:10000"], activation_note: "List active within 1-2 minutes.", + created_at: "2026-05-01T00:00:00Z", + }] }) } }, + }); + const res = await listProxyListsHandler(http)({ proxy_id: "prx_abc" }); + expect(res.isError).toBeFalsy(); + expect(res.structuredContent?.lists).toHaveLength(1); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("per-request"); + expect(t.text).toContain("us,ca"); + }); + + it("empty lists → toolError", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/prx_abc", { + status: 200, headers: new Headers(), + body: { success: true, data: { proxy: proxyResp("prx_abc", { lists: [] }) } }, + }); + const res = await listProxyListsHandler(http)({ proxy_id: "prx_abc" }); + expect(res.isError).toBe(true); + }); + + it("propagates request_id on PROXY_NOT_FOUND", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/proxies/prx_missing", { + status: 404, headers: new Headers(), + body: { success: false, error: { code: "PROXY_NOT_FOUND", message: "Proxy not found.", request_id: "req_listmissing", docs_url: "" } }, + }); + const res = await listProxyListsHandler(http)({ proxy_id: "prx_missing" }); + expect(res.isError).toBe(true); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("req_listmissing"); + }); +}); + +// ── create_proxy_list ─────────────────────────────────────────────────────── + +describe("create_proxy_list", () => { + function listFixture(id: string, overrides: Record = {}) { + return { + id, proxy_id: "prx_abc", name: "Test", + country: null, countries: null, region: null, city: null, isp: null, zip: null, + rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port", + credentials: { host: "proxy.voidmob.com", port: 10000, protocol: "http", username: "u", password: "p" }, + entries: ["u:p@proxy.voidmob.com:10000"], activation_note: "List active within 1-2 minutes.", + created_at: "2026-05-01T00:00:00Z", + ...overrides, + }; + } + + it("single country → POST /v1/proxies/:id/lists with country + defaults + idempotency key", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/proxies/prx_abc/lists", { + status: 201, headers: new Headers(), + body: { success: true, data: { list: listFixture("lst_new", { country: "us" }) } }, + }); + const res = await createProxyListHandler(http)({ + proxy_id: "prx_abc", + name: "Test", + country: "us", + }); + expect(res.isError).toBeFalsy(); + expect(http.history[0].body).toMatchObject({ + name: "Test", country: "us", rotation_period_seconds: 0, rotation_mode: "instant", format: "login_pass_host_port", + }); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("u:p@proxy.voidmob.com:10000"); + }); + + it("neither country nor countries → toolError (no HTTP call)", async () => { + const http = createMockHttpClient(); + const res = await createProxyListHandler(http)({ proxy_id: "prx_abc", name: "Test" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("country AND countries together → toolError (no HTTP call)", async () => { + const http = createMockHttpClient(); + const res = await createProxyListHandler(http)({ + proxy_id: "prx_abc", name: "Test", country: "us", countries: ["us", "gb"], + }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("countries with a subfilter → toolError (no HTTP call)", async () => { + const http = createMockHttpClient(); + const res = await createProxyListHandler(http)({ + proxy_id: "prx_abc", name: "Test", countries: ["us", "gb"], region: "California", + }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("countries (multi) → POST with countries array, no subfilters", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/proxies/prx_abc/lists", { + status: 201, headers: new Headers(), + body: { success: true, data: { list: listFixture("lst_new", { countries: ["us", "gb"] }) } }, + }); + const res = await createProxyListHandler(http)({ + proxy_id: "prx_abc", + name: "Test", + countries: ["us", "gb"], + }); + expect(res.isError).toBeFalsy(); + expect(http.history[0].body).toMatchObject({ countries: ["us", "gb"] }); + expect(http.history[0].body).not.toHaveProperty("country"); + }); +}); + +// ── delete_proxy_list ─────────────────────────────────────────────────────── + +describe("delete_proxy_list", () => { + it("DELETE /v1/proxies/:id/lists/:lid with idempotency key", async () => { + const http = createMockHttpClient(); + http.expect("DELETE", "/v1/proxies/prx_abc/lists/lst_xyz", { + status: 204, headers: new Headers(), + body: { success: true, data: null }, + }); + const res = await deleteProxyListHandler(http)({ proxy_id: "prx_abc", list_id: "lst_xyz" }); + expect(res.isError).toBeFalsy(); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + }); +}); diff --git a/tests/tools/sms.test.ts b/tests/tools/sms.test.ts new file mode 100644 index 0000000..7059a92 --- /dev/null +++ b/tests/tools/sms.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from "vitest"; +import { + searchSmsServicesHandler, + getRentalHandler, + rentNumberHandler, + cancelRentalHandler, + reuseNumberHandler, + reRentRentalHandler, + toggleAutoRenewHandler, +} from "../../src/tools/sms.js"; +import { createMockHttpClient } from "../mock-http.js"; + +describe("search_sms_services", () => { + it("calls GET /v1/services and renders a table", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + services: [ + { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }, + { id: "svc_wa", name: "WhatsApp", quoted_price_cents: 42 }, + ], + }, + }, + }); + const handler = searchSmsServicesHandler(http); + const res = await handler({}); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("Telegram"); + expect(t.text).toContain("$0.35"); + expect(res.structuredContent?.services).toHaveLength(2); + }); + + it("filters by query substring on service name", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, + headers: new Headers(), + body: { + success: true, + data: { + services: [ + { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }, + { id: "svc_wa", name: "WhatsApp", quoted_price_cents: 42 }, + ], + }, + }, + }); + const handler = searchSmsServicesHandler(http); + const res = await handler({ query: "tele" }); + expect(res.structuredContent?.services).toHaveLength(1); + }); +}); + +describe("get_rental", () => { + function verFixture() { + return { + id: "ver_abc", + status: "waiting_for_code", + phone_number: "+14155550123", + service_id: "svc_tg", + service_name: "Telegram", + charged_price_cents: 35, + expires_at: "2026-05-21T19:00:00Z", + can_cancel: true, + created_at: "2026-05-21T18:40:00Z", + reuse_counter: 0, + allow_reuse: false, + allow_paid_reuse: false, + paid_reuse_price_cents: 50, + messages: [], + }; + } + function rntFixture() { + return { + id: "ren_xyz", + display_id: "LTR456", + status: "active", + phone_number: "+14155550123", + service_id: "svc_tg", + service_name: "Telegram", + country: "us", + duration: "7D", + rental_type: "rental", + charged_price_cents: 500, + auto_renew: false, + next_renewal_price_cents: 500, + re_rent_available: false, + re_rent_price_cents: null, + re_rent_blocked_at: null, + created_at: "2026-05-21T00:00:00Z", + paid_until: "2026-05-28T00:00:00Z", + expires_at: "2026-05-28T00:00:00Z", + can_cancel: true, + cancel_window_expires_at: "2026-05-21T01:00:00Z", + messages: [], + }; + } + + it("routes ver_ ID to /v1/verifications/:id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/verifications/ver_abc", { + status: 200, + headers: new Headers(), + body: { success: true, data: { verification: verFixture() } }, + }); + const res = await getRentalHandler(http)({ rental_id: "ver_abc" }); + expect(res.structuredContent?.verification).toMatchObject({ id: "ver_abc" }); + }); + + it("routes ren_ ID to /v1/rentals/:id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/rentals/ren_xyz", { + status: 200, + headers: new Headers(), + body: { success: true, data: rntFixture() }, + }); + const res = await getRentalHandler(http)({ rental_id: "ren_xyz" }); + expect(res.structuredContent?.rental).toMatchObject({ id: "ren_xyz" }); + }); + + it("rejects bad ID prefix without an HTTP call", async () => { + const http = createMockHttpClient(); + const res = await getRentalHandler(http)({ rental_id: "bad_id" }); + expect(res.isError).toBe(true); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toMatch(/ver_|ren_/); + expect(http.history).toHaveLength(0); // no HTTP call made + }); + + it("maps VERIFICATION_NOT_FOUND error with request_id end-to-end", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/verifications/ver_missing", { + status: 404, + headers: new Headers(), + body: { + success: false, + error: { code: "VERIFICATION_NOT_FOUND", message: "Verification not found.", request_id: "req_xyz", docs_url: "" }, + }, + }); + const res = await getRentalHandler(http)({ rental_id: "ver_missing" }); + expect(res.isError).toBe(true); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("req_xyz"); // request_id flowed end-to-end + }); +}); + +describe("rent_number (verification path)", () => { + it("quotes via /v1/services then commits with strict-tie max_price_cents + idempotency key", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } }, + }); + http.expect("POST", "/v1/verifications", { + status: 201, headers: new Headers(), + body: { + success: true, + data: { + verification: { + id: "ver_new", status: "waiting_for_code", phone_number: "+14155550123", + service_id: "svc_tg", service_name: "Telegram", charged_price_cents: 35, + expires_at: "2026-05-21T19:00:00Z", can_cancel: true, + created_at: "2026-05-21T18:40:00Z", reuse_counter: 0, + allow_reuse: false, allow_paid_reuse: false, paid_reuse_price_cents: 50, + messages: [], + }, + }, + }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" }); + expect(res.isError).toBeFalsy(); + expect(http.history).toHaveLength(2); + expect(http.history[1].method).toBe("POST"); + expect(http.history[1].body).toMatchObject({ service_id: "svc_tg", max_price_cents: 35 }); + expect(http.history[1].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.verification).toMatchObject({ id: "ver_new" }); + }); + + it("surfaces PRICE_OVER_CAP with quote/available delta + request_id", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } }, + }); + http.expect("POST", "/v1/verifications", { + status: 409, headers: new Headers(), + body: { + success: false, + error: { code: "PRICE_OVER_CAP", message: "...", request_id: "req_pricecap", details: { max_price_cents: 35, available_price_cents: 42 }, docs_url: "" }, + }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" }); + expect(res.isError).toBe(true); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("$0.35"); + expect(t.text).toContain("$0.42"); + expect(t.text).toContain("req_pricecap"); // request_id flows + }); + + it("returns toolError when service not in catalog (no commit attempted)", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_other", name: "Other", quoted_price_cents: 50 }] } }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "verification" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(1); // no POST + }); +}); + +describe("rent_number (rental + dedicated paths)", () => { + // Real rental shape: rental_type (not kind), uppercase duration, country, etc. + function rentalResp(id: string, overrides: Record = {}) { + return { + id, display_id: "LTR789", status: "active", phone_number: "+14155550123", + service_id: "svc_tg", service_name: "Telegram", country: "us", duration: "7D", + rental_type: "rental", charged_price_cents: 500, auto_renew: false, + next_renewal_price_cents: 500, re_rent_available: false, re_rent_price_cents: null, + re_rent_blocked_at: null, created_at: "x", paid_until: "x", expires_at: "x", + can_cancel: true, cancel_window_expires_at: "x", messages: [], + ...overrides, + }; + } + + it("rental kind → POST /v1/rentals with uppercase duration body and tied max_price_cents (no kind field)", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35, ltr_7d_price_cents: 500 }] } }, + }); + http.expect("POST", "/v1/rentals", { + status: 201, headers: new Headers(), + body: { success: true, data: rentalResp("ren_new") }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental", duration: "7d" }); + expect(http.history[1].body).toMatchObject({ service_id: "svc_tg", duration: "7D", max_price_cents: 500 }); + expect(http.history[1].body).not.toHaveProperty("kind"); + expect(res.structuredContent?.rental).toMatchObject({ id: "ren_new" }); + }); + + it("rental kind without duration → toolError without HTTP call", async () => { + const http = createMockHttpClient(); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("rental tier not offered (price 0) → toolError without commit", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35, ltr_7d_price_cents: 0 }] } }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "rental", duration: "7d" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(1); + }); + + it("dedicated kind → POST /v1/rentals with svc_dedicated_28d + 28D using ltr_28d_price_cents", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [ + { id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }, + { id: "svc_dedicated_28d", name: "Dedicated Number", quoted_price_cents: 1999, ltr_28d_price_cents: 1999 }, + ] } }, + }); + http.expect("POST", "/v1/rentals", { + status: 201, headers: new Headers(), + body: { success: true, data: rentalResp("ren_ded", { duration: "28D", charged_price_cents: 1999 }) }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "dedicated" }); + expect(res.isError).toBeFalsy(); + expect(http.history[1].body).toMatchObject({ service_id: "svc_dedicated_28d", duration: "28D", max_price_cents: 1999 }); + expect(res.structuredContent?.rental).toMatchObject({ id: "ren_ded" }); + }); + + it("dedicated kind when svc_dedicated_28d not in catalog → toolError", async () => { + const http = createMockHttpClient(); + http.expect("GET", "/v1/services", { + status: 200, headers: new Headers(), + body: { success: true, data: { services: [{ id: "svc_tg", name: "Telegram", quoted_price_cents: 35 }] } }, + }); + const res = await rentNumberHandler(http)({ service_id: "svc_tg", kind: "dedicated" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(1); + }); +}); + +describe("cancel_rental", () => { + it("ver_ ID → POST /v1/verifications/:id/cancel with idempotency", async () => { + const http = createMockHttpClient(); + // Cancel returns a SLIM verification object, not the full resource. + http.expect("POST", "/v1/verifications/ver_abc/cancel", { + status: 200, headers: new Headers(), + body: { success: true, data: { verification: { id: "ver_abc", status: "cancelled", refunded_cents: 15 } } }, + }); + const res = await cancelRentalHandler(http)({ rental_id: "ver_abc" }); + expect(res.isError).toBeFalsy(); + const t = res.content[0]; if (t.type !== "text") throw new Error("text"); + expect(t.text).toContain("Refunded $0.15"); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("ren_ ID → DELETE /v1/rentals/:id with idempotency", async () => { + const http = createMockHttpClient(); + http.expect("DELETE", "/v1/rentals/ren_xyz", { + status: 200, headers: new Headers(), + body: { success: true, data: { id: "ren_xyz", status: "cancelled", phone_number: "x", service_id: "x", service_name: "x", country: "us", duration: "7D", rental_type: "rental", charged_price_cents: 0, auto_renew: false, next_renewal_price_cents: 0, re_rent_available: false, re_rent_price_cents: null, re_rent_blocked_at: null, paid_until: "x", expires_at: "x", created_at: "x", can_cancel: false, messages: [] } }, + }); + const res = await cancelRentalHandler(http)({ rental_id: "ren_xyz" }); + expect(res.isError).toBeFalsy(); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + }); +}); + +describe("reuse_number, re_rent_rental, toggle_auto_renew", () => { + function verResp(id: string) { + return { success: true, data: { verification: { id, status: "waiting_for_code", phone_number: "x", service_id: "x", service_name: "x", charged_price_cents: 0, expires_at: "x", can_cancel: true, created_at: "x", reuse_counter: 1, allow_reuse: false, allow_paid_reuse: false, paid_reuse_price_cents: 50, messages: [] } } }; + } + function rntResp(id: string, auto_renew = false) { + return { success: true, data: { id, status: "active", phone_number: "x", service_id: "x", service_name: "x", country: "us", duration: "7D", rental_type: "rental", charged_price_cents: 500, auto_renew, next_renewal_price_cents: 500, re_rent_available: false, re_rent_price_cents: null, re_rent_blocked_at: null, paid_until: "x", expires_at: "x", created_at: "x", can_cancel: true, messages: [] } }; + } + + it("reuse_number free path → POST /v1/verifications/:id/reuse", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/verifications/ver_abc/reuse", { status: 200, headers: new Headers(), body: verResp("ver_abc") }); + const res = await reuseNumberHandler(http)({ rental_id: "ver_abc", paid: false }); + expect(res.isError).toBeFalsy(); + }); + + it("reuse_number paid path → POST /v1/verifications/:id/reuse/paid", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/verifications/ver_abc/reuse/paid", { status: 200, headers: new Headers(), body: verResp("ver_abc") }); + await reuseNumberHandler(http)({ rental_id: "ver_abc", paid: true }); + expect(http.history[0].path).toBe("/v1/verifications/ver_abc/reuse/paid"); + }); + + it("reuse_number rejects ren_ prefix", async () => { + const http = createMockHttpClient(); + const res = await reuseNumberHandler(http)({ rental_id: "ren_xyz", paid: false }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("re_rent_rental → POST /v1/rentals/:id/re_rent with no body + idempotency", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/rentals/ren_xyz/re_rent", { status: 200, headers: new Headers(), body: rntResp("ren_new") }); + const res = await reRentRentalHandler(http)({ rental_id: "ren_xyz" }); + expect(http.history[0].body).toBeUndefined(); + expect(http.history[0].headers["Idempotency-Key"]).toMatch(/^[0-9a-f-]{36}$/); + expect(res.structuredContent?.rental).toMatchObject({ id: "ren_new" }); + }); + + it("re_rent_rental rejects ver_ prefix", async () => { + const http = createMockHttpClient(); + const res = await reRentRentalHandler(http)({ rental_id: "ver_abc" }); + expect(res.isError).toBe(true); + expect(http.history).toHaveLength(0); + }); + + it("toggle_auto_renew → POST /v1/rentals/:id/auto_renew", async () => { + const http = createMockHttpClient(); + http.expect("POST", "/v1/rentals/ren_xyz/auto_renew", { status: 200, headers: new Headers(), body: rntResp("ren_xyz", true) }); + await toggleAutoRenewHandler(http)({ rental_id: "ren_xyz", auto_renew: true }); + expect(http.history[0].body).toMatchObject({ auto_renew: true }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8d4e7cc..5fd188d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "declaration": true + "declaration": true, + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d138870 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 5000, + }, +});