diff --git a/Cargo.lock b/Cargo.lock index 0f7531d..508fff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,6 +1496,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "sha2", "tempfile", "thiserror 1.0.69", "time", @@ -1508,9 +1509,9 @@ dependencies = [ [[package]] name = "quicknode-sdk" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23681a7c94665c8717f3f7eafb8da9080d8d2e0eeb3ea9c108eed38e30901ea9" +checksum = "83d7fd1e431f62f6f82adca349756b31a07e082446c0664a81530e4a791c62ca" dependencies = [ "config", "reqwest 0.13.4", @@ -1518,6 +1519,7 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "tokio", "url", ] @@ -1574,7 +1576,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2796,7 +2798,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2805,16 +2807,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2832,31 +2825,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2865,96 +2841,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 6648d37..9121629 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.6" +quicknode-sdk = "0.7" clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap_complete = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } @@ -39,6 +39,9 @@ time = { version = "0.3", features = ["formatting", "parsing", "macros"] } base64 = "0.22" tempfile = "3" url = "2" +# Stable fingerprint of the API key for scoping the JWT token cache to an +# account. The key itself is never written to the cache — only this hash. +sha2 = "0.10" [dev-dependencies] reqwest = { version = "0.12", default-features = false } diff --git a/README.md b/README.md index 263389e..dced139 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,55 @@ qn sql schema hyperliquid-core-mainnet Queries are read-only (SELECT) and capped at 1000 rows per request; page through larger result sets with `LIMIT`/`OFFSET` in the SQL. +### On-chain RPC + +Make JSON-RPC calls with no endpoint to provision. `qn rpc call` mints and +refreshes a short-lived session JWT automatically; the only one-time step is +enabling Tooling Access (or pass `--yes` to enable on first use). + +```sh +qn tooling-access enable # one-time; idempotent, requires an admin role +qn tooling-access status + +qn rpc call eth_blockNumber +qn rpc call eth_getBalance '["0xabc...", "latest"]' +qn rpc call eth_call '{"to":"0x..."}' +qn rpc call eth_call --params-file params.json # read params from a file (-f) +echo '[...]' | qn rpc call eth_call - # read params from stdin + +qn rpc call eth_blockNumber --yes # auto-enable Tooling Access if needed + +# Multichain: the endpoint serves many chains. Target one by its network key. +qn rpc list-networks # list available network keys (alias: ls) +qn rpc call getSlot --network solana-mainnet +qn rpc call eth_chainId --network polygon + +# Custom endpoint: send the call to a fully-formed HTTP URL instead of Tooling +# Access. The URL is self-authenticating (no session token is minted or sent). +qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc +``` + +Set a default custom endpoint in `~/.config/qn/config.toml` so every `qn rpc call` +uses it without the flag (a per-call `--endpoint-url` still overrides it): + +```toml +[rpc] +endpoint_url = "https://my-endpoint.example/rpc" +``` + +`--endpoint-url` and `--network` are mutually exclusive: a custom URL is not +multichain-routed. + +The network map is cached in `~/.config/qn/networks.toml` (per endpoint, 24h TTL), +so `--network` calls reuse it without re-fetching. Network keys are the endpoint's +own `multichain_urls` keys (note these can differ from chain slugs, e.g. `polygon` +not `matic`); `qn rpc list-networks` shows the exact set. + +The session token is cached under `~/.config/qn/tokens.toml` (0600), scoped to the +API key, so subsequent calls skip the mint round trip while it's valid. Results are +schemaless JSON; `-o json|yaml|toon` controls the format (`table`/`md` fall back to +JSON). + ### Other ```sh diff --git a/src/cli.rs b/src/cli.rs index 4c865a9..1cf73f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -94,6 +94,12 @@ pub struct Cli { #[arg(long, global = true, hide = true)] pub base_url: Option, + /// Path prefix inserted between the host and each sub-client's path (e.g. + /// `/console-api`). For reverse-proxy / gateway environments. Requires + /// `--base-url`. + #[arg(long, global = true, hide = true)] + pub base_prefix: Option, + /// Print help (see a summary with '-h'). #[arg(short = 'h', long, global = true, action = ArgAction::Help)] pub help: Option, @@ -149,6 +155,13 @@ pub enum Command { /// Run SQL queries and inspect cluster schemas. Sql(commands::sql::Args), + /// Make JSON-RPC calls against your Tooling Access endpoint. + Rpc(commands::rpc::Args), + + /// Manage Tooling Access (the endpoint `qn rpc` uses). + #[command(name = "tooling-access")] + ToolingAccess(commands::tooling_access::Args), + /// Generate shell completion scripts. /// /// When installing qn through a package manager, it's possible that no @@ -206,6 +219,7 @@ impl Cli { yes_count: self.yes, retries: self.retries, base_url: self.base_url.clone(), + base_prefix: self.base_prefix.clone(), } } @@ -239,6 +253,12 @@ impl Cli { Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await, Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await, Command::Sql(args) => commands::sql::run(args, Ctx::from_global(global)?).await, + // rpc builds its own Ctx (it seeds the SDK from the on-disk token + // cache before construction), so it takes `global` directly. + Command::Rpc(args) => commands::rpc::run(args, global).await, + Command::ToolingAccess(args) => { + commands::tooling_access::run(args, Ctx::from_global(global)?).await + } } } } diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 4012326..f6cf74a 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -117,6 +117,17 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase - `kv` — `set` (put, get, list, delete, bulk) and `list` (list, get, create, append, contains, remove-item, update, delete) - `sql` — query (inline SQL, `--file `, or `--file -` for stdin), schema +- `tooling-access` — status, enable, disable (provisions the endpoint `rpc` uses) +- `rpc` — make JSON-RPC calls. `qn rpc call [json-params]` calls the + account's Tooling Access endpoint (params is a JSON array or object inline, or + `--params-file ` / `-f` to read from a file, or `-` for stdin); the + session JWT is minted and refreshed automatically. On a + not-yet-enabled account it auto-enables with `--yes` (or prompts on a TTY). + Multichain: `--network ` targets a specific chain by its key (e.g. + `solana-mainnet`, `polygon`); `qn rpc list-networks` (alias `ls`) lists the + available keys. Custom endpoint: `--endpoint-url ` (or `[rpc] endpoint_url` + in config) sends the call to a fully-formed HTTP URL that authenticates itself + (no token minted); it's mutually exclusive with `--network`. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -171,6 +182,21 @@ qn kv set get my-key qn kv set list ``` +**Make on-chain calls (no endpoint to provision):** + +```sh +qn tooling-access enable --yes # one-time; idempotent, admin role required +qn rpc call eth_blockNumber # → "0x…" (default network) +qn rpc call eth_getBalance '["0xabc...", "latest"]' +qn rpc list-networks # available network keys for this endpoint +qn rpc call getSlot --network solana-mainnet +qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc +``` + +`qn rpc call` mints and refreshes the session JWT for you; the only one-time step +is enabling Tooling Access (or pass `--yes` to enable on first use). A custom +`--endpoint-url` (or `[rpc] endpoint_url` in config) bypasses that entirely. + ## 8. Gotchas & safety rails - Mutations are never retried; re-running a failed create can double-provision (§5). diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5f2a5aa..50fd635 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,8 +10,10 @@ pub mod chain; pub mod endpoint; pub mod kv; pub mod metrics; +pub mod rpc; pub mod sql; pub mod stream; pub mod team; +pub mod tooling_access; pub mod usage; pub mod webhook; diff --git a/src/commands/rpc.rs b/src/commands/rpc.rs new file mode 100644 index 0000000..924fb28 --- /dev/null +++ b/src/commands/rpc.rs @@ -0,0 +1,542 @@ +//! `qn rpc call [params]` — make a JSON-RPC call against the account's +//! Tooling Access endpoint (or a custom URL), and `qn rpc list-networks` — list +//! the multichain endpoint's available network keys. +//! +//! By default there's no endpoint URL or token to manage: the SDK mints and +//! refreshes a short-lived session JWT automatically. Because each CLI +//! invocation is a fresh process, we persist that JWT to `tokens.toml` and +//! re-seed it next time (see `crate::config` token cache), so a valid token +//! means no control-plane round trip. +//! +//! On a never-provisioned account the first call fails with "not enabled"; we +//! offer to enable (prompt on a TTY, `--yes` for scripts/agents, otherwise an +//! actionable error), then retry. +//! +//! A custom endpoint URL (`--endpoint-url` per call, or `[rpc] endpoint_url` in +//! config) is a separate lane: the SDK sends the call straight to that URL with +//! no JWT minted or attached. That lane never touches the token cache or the +//! Tooling Access enable/probe recovery. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use clap::{ArgGroup, Args as ClapArgs, Subcommand}; +use serde_json::Value; + +use crate::confirm::{decide_without_prompt, ConfirmCfg, Severity}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::Format; +use crate::retry::retrying; +use crate::{config, confirm}; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +pub struct Args { + #[command(subcommand)] + pub cmd: RpcCmd, +} + +#[derive(Debug, Subcommand)] +pub enum RpcCmd { + /// Make a JSON-RPC call. + #[command(after_help = "Examples:\n \ + qn rpc call eth_blockNumber\n \ + qn rpc call eth_getBalance '[\"0xabc...\", \"latest\"]'\n \ + qn rpc call getSlot --network solana-mainnet\n \ + qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc\n \ + qn rpc call eth_call --params-file params.json\n \ + echo '[...]' | qn rpc call eth_call -\n \ + cat params.json | qn rpc call eth_call -f -")] + Call(CallArgs), + + /// List the endpoint's available network keys (no RPC call). + #[command(visible_alias = "ls")] + ListNetworks, +} + +#[derive(Debug, ClapArgs)] +#[command(group(ArgGroup::new("params_source").args(["params", "params_file"])))] +pub struct CallArgs { + /// The JSON-RPC method, e.g. `eth_blockNumber`. + #[arg(value_name = "METHOD")] + pub method: String, + + /// JSON params: an array (positional) or object (by-name). Pass `-` to read + /// the JSON from stdin. Omit for no params (sends `[]`). Mutually exclusive + /// with --params-file. + /// + /// To auto-enable Tooling Access when it isn't provisioned yet, pass the + /// global `--yes`/`-y` flag (required in non-interactive contexts). + #[arg(value_name = "PARAMS")] + pub params: Option, + + /// Read JSON params from a file, or from stdin when the path is `-`. + /// Mutually exclusive with the positional params argument. + #[arg(long, short = 'f', value_name = "PATH")] + pub params_file: Option, + + /// Target network on the multichain endpoint, by its key (e.g. + /// `solana-mainnet`, `polygon`, `btc`). Omit for the endpoint's default + /// network. Run `qn rpc list-networks` to see available keys. + #[arg(long)] + pub network: Option, + + /// Send the call to a fully-formed custom HTTP URL instead of the account's + /// Tooling Access endpoint. The URL is self-authenticating: no session token + /// is minted or attached. Overrides `[rpc] endpoint_url` in config. Mutually + /// exclusive with `--network` (a custom URL is not multichain-routed). + #[arg(long, conflicts_with = "network", value_name = "URL")] + pub endpoint_url: Option, +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + match args.cmd { + RpcCmd::Call(call) => run_call(call, global).await, + RpcCmd::ListNetworks => run_list_networks(global).await, + } +} + +/// `qn rpc list-networks` — always targets the account's Tooling Access +/// multichain endpoint (a custom `endpoint_url` has no network map), so it +/// ignores any configured or flagged custom URL. +async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { + let config_path = global.resolve_config_path(); + let networks_path = config::networks_cache_path(config_path.as_deref()); + let token_path = config::token_cache_path(config_path.as_deref()); + let seed = match (&token_path, resolve_key_quietly(&global)) { + (Some(p), Some(key)) => config::load_token(p, &key), + _ => None, + }; + let (ctx, _api_key) = Ctx::from_global_with_rpc_seed(global, seed, None)?; + let map = ensure_networks(&ctx, networks_path.as_deref()).await?; + emit_networks(&ctx, &map) +} + +async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + // A custom URL (per-call flag, else the `[rpc] endpoint_url` config default) + // is a separate lane: no JWT, no token cache, no Tooling Access recovery. + // Validate the flag here; the config value is validated when `Ctx` builds it. + let flag_endpoint_url = match args.endpoint_url.as_deref() { + Some(u) => Some(crate::context::validate_endpoint_url(u)?), + None => None, + }; + let config_endpoint_url = load_config_endpoint_url(&global); + let custom_url = flag_endpoint_url + .clone() + .or_else(|| config_endpoint_url.clone()); + + // Load any cached token to seed the SDK and avoid a mint round trip. We need + // the resolved API key to scope the cache, so resolve the config path first. + let config_path = global.resolve_config_path(); + let token_path = config::token_cache_path(config_path.as_deref()); + let networks_path = config::networks_cache_path(config_path.as_deref()); + + // We don't know the API key until Ctx builds it, but the cache is keyed by + // the key's fingerprint. Resolve it once here for the load; Ctx re-resolves + // and returns it for the write-back (cheap, and keeps Ctx the source of truth). + let seed = match (&token_path, resolve_key_quietly(&global)) { + (Some(p), Some(key)) => config::load_token(p, &key), + _ => None, + }; + + let (ctx, api_key) = Ctx::from_global_with_rpc_seed(global, seed, config_endpoint_url)?; + + // Multichain selection (--network) needs the per-network URL map. Resolve it + // lazily — only when a network is involved — so the common default-network + // call path stays a single round trip. (A custom URL conflicts with + // --network at the clap layer, so this only runs on the Tooling Access lane.) + if args.network.is_some() { + let map = ensure_networks(&ctx, networks_path.as_deref()).await?; + ctx.sdk.rpc.set_networks(map); + } + + let method = args.method.as_str(); + + // First attempt. When a custom URL is active, the call goes straight there + // (self-authenticating) and any error surfaces directly — the Tooling Access + // enable/probe recovery below only applies to the token-minting lane. + // + // On the Tooling Access lane, both ways of discovering "disabled" converge on + // the same flow: offer to enable (y/N on a TTY, --yes to auto-enable, + // actionable error otherwise), then retry. + // - mint returns the "not enabled" 400 (no usable token), or + // - the call connect/timeouts against a stale-but-unexpired cached token + // and a status probe confirms the endpoint is disabled (possibly + // out-of-band). That path also clears the stale token first. + let result = match call_once( + &ctx, + method, + ¶ms, + args.network.clone(), + custom_url.clone(), + ) + .await + { + Ok(v) => v, + Err(e) if custom_url.is_some() => return Err(map_unknown_network(e)), + Err(e) if is_not_enabled(&e) => { + maybe_enable(&ctx).await?; + call_after_enable(&ctx, method, ¶ms, args.network.clone()).await? + } + Err(e) if is_transport_failure(&e) => { + if disabled_per_status(&ctx).await { + // The stale token points at the disabled endpoint. Drop it both + // in memory (so this retry mints fresh) and on disk (so the next + // process does too) before enabling and retrying. + ctx.sdk.rpc.clear_cached_token(); + if let Some(p) = &token_path { + let _ = config::delete_config(p); + } + maybe_enable(&ctx).await?; + call_after_enable(&ctx, method, ¶ms, args.network.clone()).await? + } else { + // Status enabled (real endpoint blip) or the probe itself failed + // (genuine network issue): the honest transport error. + return Err(e); + } + } + Err(e) => return Err(map_unknown_network(e)), + }; + + // Snapshot the (possibly refreshed) token and write it back. On the custom-URL + // lane no token is minted, so `current_token()` is `None` and nothing is + // written — the existing cache is left untouched. Otherwise we always persist + // when a token is present: the write is an idempotent atomic replace, the + // token is short-lived, and re-writing an unchanged token is harmless. + // Best-effort — a cache write failure must not fail the call, which already + // succeeded; the next run simply re-mints. + if let (Some(p), Some(current)) = (&token_path, ctx.sdk.rpc.current_token()) { + let _ = config::save_token(p, &api_key, ¤t); + } + + emit_result(&ctx, &result) +} + +/// Reads `[rpc] endpoint_url` from the resolved config file, if any. Swallows +/// load errors (a broken config surfaces later when `Ctx` builds); returns the +/// raw value, which `Ctx::build` validates. +fn load_config_endpoint_url(global: &GlobalArgs) -> Option { + let path = global.resolve_config_path()?; + match config::load_from(&path) { + Ok(Some(cfg)) => cfg.rpc.endpoint_url, + _ => None, + } +} + +/// Re-render the SDK's "unknown network" config error with CLI wording that +/// points at `qn rpc list-networks`, dropping the SDK-internal +/// `set_networks()` hint. Any other error passes through unchanged. +fn map_unknown_network(err: CliError) -> CliError { + if let CliError::Sdk(quicknode_sdk::errors::SdkError::Config(msg)) = &err { + if msg.contains("unknown network") { + // Keep the "Available: ..." list the SDK computed; replace only the + // SDK-internal seeding hint with the CLI discovery command. + let available = msg + .split_once("Available:") + .map(|(_, rest)| rest.trim()) + .filter(|s| !s.is_empty()); + let mut out = "unknown network key for this endpoint.".to_string(); + if let Some(list) = available { + out.push_str(&format!(" Available: {list}")); + } + out.push_str("\nRun 'qn rpc list-networks' to see valid keys."); + return CliError::Arg(out); + } + } + err +} + +/// Current unix time in seconds, for the networks-cache TTL. +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Returns the endpoint's per-network URL map (key -> http_url), using the +/// `networks.toml` cache when fresh (24h TTL) and otherwise fetching it via +/// `get_endpoint_urls` and rewriting the cache. Requires Tooling Access to be +/// enabled (the endpoint id comes from status). +async fn ensure_networks( + ctx: &Ctx, + networks_path: Option<&std::path::Path>, +) -> Result, CliError> { + // Need the endpoint id to scope the cache and fetch URLs. + let status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?; + let Some(endpoint_id) = status.endpoint_id else { + return Err(CliError::Arg( + "this account's Tooling Access endpoint did not report an id, so per-network \ + routing is unavailable. Omit --network to use the default network." + .to_string(), + )); + }; + + // Fresh cache hit? + if let Some(p) = networks_path { + if let Some(map) = config::load_networks(p, &endpoint_id, now_unix()) { + return Ok(map); + } + } + + // Miss/stale: fetch and rewrite. + let resp = retrying(ctx.global.retries, || { + ctx.sdk.admin.get_endpoint_urls(&endpoint_id) + }) + .await?; + let map: std::collections::HashMap = resp + .data + .and_then(|d| d.multichain_urls) + .map(|mc| mc.into_iter().map(|(k, v)| (k, v.http_url)).collect()) + .unwrap_or_default(); + if let Some(p) = networks_path { + let _ = config::save_networks(p, &endpoint_id, now_unix(), &map); + } + Ok(map) +} + +/// Print the available network keys, one per line (or as JSON/yaml/toon). +fn emit_networks( + ctx: &Ctx, + map: &std::collections::HashMap, +) -> Result<(), CliError> { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + // Default to a plain one-key-per-line list (friendly for discovery, TTY or + // piped). Only an explicit structured format produces the JSON envelope. + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ "networks": keys }); + return emit_result(ctx, &v); + } + for k in keys { + println!("{k}"); + } + Ok(()) +} + +/// Resolve the API key without prompting, swallowing errors (the real +/// resolution + error happens in `Ctx::build`). Used only to scope the cache +/// load before `Ctx` is constructed. +fn resolve_key_quietly(global: &GlobalArgs) -> Option { + let path = global.resolve_config_path(); + config::resolve_api_key(global.api_key.as_deref(), path.as_deref(), false, || { + Err(CliError::NoApiKey) + }) + .ok() + .map(|(k, _)| k) +} + +async fn call_once( + ctx: &Ctx, + method: &str, + params: &Option, + network: Option, + endpoint_url: Option, +) -> Result { + // RPC reads are safe to retry on transient transport failures, same as + // other read-only commands, on both the Tooling Access and custom-URL lanes. + // The SDK handles its own one-shot 401 refresh (Tooling Access lane only). + retrying(ctx.global.retries, || { + ctx.sdk.rpc.call( + method, + params.clone(), + network.clone(), + endpoint_url.clone(), + ) + }) + .await + .map_err(Into::into) +} + +// Total wall-clock budget for retrying a call right after enabling Tooling +// Access, while the freshly-provisioned endpoint host becomes routable. +const POST_ENABLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(10); +// A just-enabled endpoint is essentially never reachable on the first instant; +// a short initial wait absorbs the common case before we even try. +const POST_ENABLE_INITIAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1); + +/// Retry the call right after enabling Tooling Access, tolerating the +/// provisioning lag where the new endpoint host isn't routable yet. Waits 1s +/// first, then retries transient (connect/timeout/5xx/429) failures with +/// exponential backoff until `POST_ENABLE_BUDGET` (~10s) is exhausted. A +/// non-transient error (e.g. a JSON-RPC or 4xx) returns immediately. +async fn call_after_enable( + ctx: &Ctx, + method: &str, + params: &Option, + network: Option, +) -> Result { + tokio::time::sleep(POST_ENABLE_INITIAL_WAIT).await; + + let deadline = tokio::time::Instant::now() + POST_ENABLE_BUDGET; + let mut backoff = std::time::Duration::from_millis(500); + loop { + // Post-enable retry only runs on the Tooling Access lane (a custom URL + // bypasses the enable flow entirely), so no custom endpoint_url here. + match ctx + .sdk + .rpc + .call(method, params.clone(), network.clone(), None) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + let cli_err = CliError::from(e); + let now = tokio::time::Instant::now(); + if !is_transport_failure(&cli_err) || now >= deadline { + return Err(cli_err); + } + // Don't sleep past the deadline. + let remaining = deadline - now; + tokio::time::sleep(backoff.min(remaining)).await; + backoff = (backoff * 2).min(std::time::Duration::from_secs(4)); + } + } + } +} + +/// True when the error is the control plane's "Tooling Access not enabled" 400. +fn is_not_enabled(err: &CliError) -> bool { + matches!( + err, + CliError::Sdk(quicknode_sdk::errors::SdkError::Api { status, body }) + if status.as_u16() == 400 && body.to_lowercase().contains("not enabled") + ) +} + +/// True for a connect/timeout transport failure (as opposed to an HTTP status +/// or JSON-RPC error). These are the ambiguous failures worth probing status for. +fn is_transport_failure(err: &CliError) -> bool { + use quicknode_sdk::errors::HttpKind; + matches!( + err, + CliError::Sdk(sdk @ quicknode_sdk::errors::SdkError::Http(_)) + if matches!(sdk.http_kind(), Some(HttpKind::Connect | HttpKind::Timeout)) + ) +} + +/// Probe `tooling_access_status` to disambiguate an RPC connect/timeout failure. +/// Returns true only if the probe succeeds and reports the endpoint disabled +/// (the case we can act on). A probe that fails (genuine network issue) or +/// reports enabled (real endpoint blip) returns false, so the caller surfaces +/// the original transport error. No retries: best-effort diagnosis on an +/// already-failed call. +async fn disabled_per_status(ctx: &Ctx) -> bool { + matches!(ctx.sdk.admin.tooling_access_status().await, Ok(s) if !s.enabled) +} + +/// Offer to enable Tooling Access: auto on `--yes`, prompt on a TTY, and an +/// actionable error in non-interactive contexts. Mirrors the confirmation +/// gating used for destructive ops (`crate::confirm`). +async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { + // The global -y/--yes (count) is consent to provision without prompting. + let cfg = ConfirmCfg::new( + ctx.global.yes_count, + ctx.global.no_input, + ctx.out.stdout_is_tty, + ); + + let proceed = match decide_without_prompt(Severity::Mild, cfg) { + Ok(p) => p, + // Non-interactive without consent: point the user at the explicit command. + Err(CliError::NeedsConfirmation) => { + return Err(CliError::Arg( + "Tooling Access is not enabled for this account. \ + Run 'qn tooling-access enable', or pass --yes to enable it now." + .to_string(), + )); + } + Err(e) => return Err(e), + }; + + let proceed = + proceed || confirm::prompt_yes_no("Tooling Access is not enabled. Enable it now?")?; + if !proceed { + return Err(CliError::Cancelled); + } + + // enable mutates account state — never retry. enable() surfaces the typed + // reason (e.g. admin-role / eligibility 4xx) which renders actionably. + ctx.sdk.admin.enable_tooling_access().await?; + ctx.out.note("✓ Enabled Tooling Access"); + Ok(()) +} + +/// Resolve params from the inline positional arg or `--params-file`, then parse +/// as JSON. `None`/`None` → no params (sends `[]`). Either source accepts `-` to +/// read from stdin. The clap `ArgGroup` guarantees at most one is set. An empty +/// value (after trimming) is treated as no params. +fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, CliError> { + let raw = match (arg, file) { + (None, None) => return Ok(None), + (Some("-"), _) => read_stdin("params")?, + (Some(s), _) => s.to_string(), + (None, Some(path)) if path.as_os_str() == "-" => read_stdin("params")?, + (None, Some(path)) => std::fs::read_to_string(path).map_err(|e| { + CliError::Arg(format!( + "could not read params file '{}': {e}", + path.display() + )) + })?, + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let value: Value = serde_json::from_str(trimmed) + .map_err(|e| CliError::Arg(format!("params is not valid JSON: {e}")))?; + Ok(Some(value)) +} + +/// Read all of stdin as a UTF-8 string, labeling errors with `what`. +fn read_stdin(what: &str) -> Result { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Arg(format!("could not read {what} from stdin: {e}")))?; + Ok(buf) +} + +/// Emit the RPC result. RPC results are schemaless JSON, so JSON is the real +/// default: a bare `qn rpc` prints JSON whether on a TTY or piped (we read the +/// raw `--format` flag, not the TTY-aware resolved default). `json`/`yaml`/`toon` +/// render as requested. `table`/`md` have no columns here, so they fall back to +/// JSON — and only that explicit case prints a one-line note on stderr. +fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { + match ctx.global.format { + None | Some(Format::Json) => { + println!( + "{}", + serde_json::to_string_pretty(result).map_err(CliError::Json)? + ); + } + Some(Format::Yaml) => { + print!( + "{}", + serde_yml::to_string(result).map_err(|e| CliError::Format(e.to_string()))? + ); + } + Some(Format::Toon) => { + println!( + "{}", + toon_format::encode_default(result).map_err(|e| CliError::Format(e.to_string()))? + ); + } + Some(fmt @ (Format::Table | Format::Md)) => { + let name = if fmt == Format::Md { "md" } else { "table" }; + // The user explicitly asked for a tabular format, which has no + // columns for schemaless RPC output; say so once, then print JSON. + ctx.out.warn(&format!( + "ℹ '-o {name}' has no columns for 'qn rpc'; printing JSON. Use -o json/yaml/toon for structured output." + )); + println!( + "{}", + serde_json::to_string_pretty(result).map_err(CliError::Json)? + ); + } + } + Ok(()) +} diff --git a/src/commands/tooling_access.rs b/src/commands/tooling_access.rs new file mode 100644 index 0000000..de41c42 --- /dev/null +++ b/src/commands/tooling_access.rs @@ -0,0 +1,92 @@ +//! `qn tooling-access {status,enable,disable}` — manage Tooling Access. +//! +//! Tooling Access provisions a single multichain, read-only endpoint for the +//! account and is the prerequisite for `qn rpc`. `enable`/`disable` require an +//! admin role; `status` works for any role. These map to the admin control +//! plane (`qn.admin.{tooling_access_status,enable,disable}`). + +use clap::{Args as ClapArgs, Subcommand}; +use comfy_table::Cell; +use serde::Serialize; + +use crate::confirm::confirm_mild; +use crate::context::Ctx; +use crate::errors::CliError; +use crate::output::{new_table, set_header_bold, write_table, Render}; +use crate::retry::retrying; + +#[derive(Debug, ClapArgs)] +#[command(after_help = "Examples:\n \ + qn tooling-access status\n \ + qn tooling-access enable # provisions the endpoint (admin role)\n \ + qn tooling-access disable")] +pub struct Args { + #[command(subcommand)] + pub cmd: ToolingAccessCmd, +} + +#[derive(Debug, Subcommand)] +pub enum ToolingAccessCmd { + /// Show whether Tooling Access is enabled and the endpoint URL. + Status, + /// Enable (provision) Tooling Access. Idempotent; requires an admin role. + Enable, + /// Disable Tooling Access, cutting off blockchain access for all Quicknode + /// developer tooling on this account. Idempotent. + Disable, +} + +pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { + match args.cmd { + ToolingAccessCmd::Status => { + // status is read-only, so retry on transient failures. + let resp = + retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?; + crate::output::emit(&ctx.out, &StatusView(resp)) + } + // enable/disable mutate account state — never retry. + ToolingAccessCmd::Enable => { + let resp = ctx.sdk.admin.enable_tooling_access().await?; + ctx.out.note("✓ Enabled Tooling Access"); + crate::output::emit(&ctx.out, &StatusView(resp)) + } + ToolingAccessCmd::Disable => { + confirm_mild( + &ctx, + "Disable Tooling Access? This disables blockchain access \ + for all Quicknode developer tooling on this account \ + until you re-enable it", + )?; + let resp = ctx.sdk.admin.disable_tooling_access().await?; + ctx.out.note("✓ Disabled Tooling Access"); + crate::output::emit(&ctx.out, &StatusView(resp)) + } + } +} + +#[derive(Serialize)] +struct StatusView(quicknode_sdk::ToolingAccessStatus); + +impl Render for StatusView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["FIELD", "VALUE"]); + t.add_row(vec![ + Cell::new("enabled"), + Cell::new(self.0.enabled.to_string()), + ]); + t.add_row(vec![ + Cell::new("endpoint_url"), + Cell::new(self.0.endpoint_url.as_deref().unwrap_or("-")), + ]); + t.add_row(vec![ + Cell::new("enabled_at"), + Cell::new(self.0.enabled_at.as_deref().unwrap_or("-")), + ]); + write_table(w, &t) + } +} diff --git a/src/config.rs b/src/config.rs index 6aadb50..a859c58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,6 +47,8 @@ pub struct ConfigFile { pub api: ApiSection, #[serde(default)] pub output: OutputSection, + #[serde(default)] + pub rpc: RpcSection, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -54,6 +56,16 @@ pub struct ApiSection { pub key: Option, } +/// `[rpc]` section: RPC-specific defaults. `endpoint_url`, when set, routes +/// `qn rpc call` at a fully-formed custom HTTP URL instead of the account's +/// Tooling Access endpoint (self-authenticating: no JWT minted). A per-call +/// `--endpoint-url` overrides it. Mirrors the SDK's `RpcConfig.endpoint_url`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct RpcSection { + #[serde(default)] + pub endpoint_url: Option, +} + #[derive(Debug, Default, Serialize, Deserialize)] pub struct OutputSection { /// Default output format when the CLI is invoked without `--format`. One @@ -212,6 +224,283 @@ fn write_config(path: &Path, cfg: &ConfigFile) -> Result<(), CliError> { Ok(()) } +// ── Tooling Access token cache ─────────────────────────────────────────────── +// +// Each `qn rpc` is a fresh process, so the SDK's in-memory JWT cache starts +// empty every time. We persist the short-lived (~10 min) session token next to +// the config (`tokens.toml`) and re-seed the SDK on the next invocation, +// avoiding a control-plane round trip while the token is still valid. +// +// Only the short-lived JWT is written here — never the long-lived API key. The +// entry is scoped to the account by a fingerprint (SHA-256) of the API key, so +// switching keys transparently invalidates a stale token rather than presenting +// one account's JWT to another's endpoint. + +use quicknode_sdk::CachedToken; + +/// On-disk shape of `~/.config/qn/tokens.toml`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct TokenCacheFile { + #[serde(default)] + pub token: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedTokenEntry { + /// SHA-256 hex of the API key this token was minted for. Never the key. + pub key_hash: String, + pub endpoint_url: String, + pub token: String, + pub exp_unix: i64, +} + +/// The token cache path: `tokens.toml` alongside the resolved config file (so +/// `--config-file` keeps config and tokens together). Falls back to the default +/// config dir when no explicit config path is given. +pub fn token_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("tokens.toml")), + None => config_dir().map(|d| d.join("qn").join("tokens.toml")), + } +} + +/// Hex SHA-256 of the API key, used to scope a cached token to its account. +pub fn fingerprint_key(api_key: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(api_key.as_bytes()); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Loads a cached token for `api_key` from `path`. Returns `None` if the file is +/// absent, unparseable, empty, or scoped to a different key (account switch). +/// A malformed cache is treated as a miss, never an error — the SDK will mint. +pub fn load_token(path: &Path, api_key: &str) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: TokenCacheFile = toml::from_str(&text).ok()?; + let entry = cache.token?; + if entry.key_hash != fingerprint_key(api_key) { + return None; + } + Some(CachedToken { + endpoint_url: entry.endpoint_url, + token: entry.token, + exp_unix: entry.exp_unix, + }) +} + +/// Saves `token` to `path` atomically with 0600 perms, scoped to `api_key`. +/// Mirrors [`save_api_key`]'s write discipline: temp file in the same dir, +/// 0600 set before the secret bytes, `rename` over the target. Last-write-wins +/// under concurrency — two `qn rpc` processes may both mint, but the atomic +/// rename guarantees no partial file and both tokens are valid. +pub fn save_token(path: &Path, api_key: &str, token: &CachedToken) -> Result<(), CliError> { + let cache = TokenCacheFile { + token: Some(CachedTokenEntry { + key_hash: fingerprint_key(api_key), + endpoint_url: token.endpoint_url.clone(), + token: token.token.clone(), + exp_unix: token.exp_unix, + }), + }; + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + + let parent = path.parent().ok_or_else(|| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other("token cache path has no parent directory"), + })?; + fs::create_dir_all(parent).map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); + } + + let mut tmp = tempfile::Builder::new() + .prefix(".qn-tokens-") + .tempfile_in(parent) + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600)).map_err(|source| { + CliError::ConfigWrite { + path: path.to_path_buf(), + source, + } + })?; + } + + use std::io::Write; + tmp.as_file_mut() + .write_all(text.as_bytes()) + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + tmp.as_file_mut() + .sync_all() + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + tmp.persist(path).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: e.error, + })?; + Ok(()) +} + +// ── Multichain network URL cache ───────────────────────────────────────────── +// +// The per-network URL map (network key -> http_url) is stable endpoint metadata, +// unlike the ~10-min JWT. We cache it separately in `networks.toml` with a +// 24-hour TTL so it isn't rewritten on every token refresh. Scoped to the +// endpoint id; re-fetched (via get_endpoint_urls) when absent or stale. + +/// Seconds the cached network map is considered fresh (24h). +pub const NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct NetworksCacheFile { + #[serde(default)] + pub entry: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworksEntry { + /// Endpoint id the map belongs to; a different id is a cache miss. + pub endpoint_id: String, + /// Unix seconds the map was fetched, for the TTL check. + pub fetched_at_unix: i64, + /// network key -> full http_url. + pub networks: std::collections::HashMap, +} + +/// The networks cache path: `networks.toml` alongside the resolved config file. +pub fn networks_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("networks.toml")), + None => config_dir().map(|d| d.join("qn").join("networks.toml")), + } +} + +/// Loads the cached network map for `endpoint_id` from `path`, if present, for +/// the same endpoint, and fetched within the TTL (relative to `now_unix`). +/// Returns `None` (a cache miss) on any mismatch or parse failure. +pub fn load_networks( + path: &Path, + endpoint_id: &str, + now_unix: i64, +) -> Option> { + let text = fs::read_to_string(path).ok()?; + let cache: NetworksCacheFile = toml::from_str(&text).ok()?; + let entry = cache.entry?; + if entry.endpoint_id != endpoint_id { + return None; + } + if now_unix.saturating_sub(entry.fetched_at_unix) >= NETWORKS_TTL_SECS { + return None; + } + Some(entry.networks) +} + +/// Saves the network map for `endpoint_id` to `path` atomically with 0600 perms, +/// stamping `fetched_at_unix` for the TTL check. Same write discipline as +/// [`save_token`]. +pub fn save_networks( + path: &Path, + endpoint_id: &str, + fetched_at_unix: i64, + networks: &std::collections::HashMap, +) -> Result<(), CliError> { + let cache = NetworksCacheFile { + entry: Some(NetworksEntry { + endpoint_id: endpoint_id.to_string(), + fetched_at_unix, + networks: networks.clone(), + }), + }; + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-networks-") +} + +/// Atomically writes `bytes` to `path` with 0600 perms via a temp file in the +/// same directory (perms set before the bytes), then `rename`. Shared by the +/// token and networks caches. +fn write_atomic_0600(path: &Path, bytes: &[u8], tmp_prefix: &str) -> Result<(), CliError> { + let parent = path.parent().ok_or_else(|| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other("cache path has no parent directory"), + })?; + fs::create_dir_all(parent).map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); + } + let mut tmp = tempfile::Builder::new() + .prefix(tmp_prefix) + .tempfile_in(parent) + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600)).map_err(|source| { + CliError::ConfigWrite { + path: path.to_path_buf(), + source, + } + })?; + } + use std::io::Write; + tmp.as_file_mut() + .write_all(bytes) + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + tmp.as_file_mut() + .sync_all() + .map_err(|source| CliError::ConfigWrite { + path: path.to_path_buf(), + source, + })?; + tmp.persist(path).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: e.error, + })?; + Ok(()) +} + +/// Deletes the saved config file. No error if it didn't exist. +pub fn delete_config(path: &Path) -> Result<(), CliError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + /// Resolves an API key per the documented precedence: flag > config file. /// /// `allow_prompt` and `prompt` exist only so `qn auth login` can opt into the @@ -418,6 +707,42 @@ mod tests { assert!(cfg.output.wide); } + #[test] + fn rpc_endpoint_url_round_trips_through_config_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[api]\nkey = \"k\"\n\n[rpc]\nendpoint_url = \"https://my-endpoint.example/rpc\"\n", + ) + .unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert_eq!( + cfg.rpc.endpoint_url.as_deref(), + Some("https://my-endpoint.example/rpc") + ); + } + + #[test] + fn rpc_section_is_optional() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[api]\nkey = \"k\"\n").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert_eq!(cfg.rpc.endpoint_url, None); + } + + #[test] + fn save_api_key_preserves_rpc_section() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[rpc]\nendpoint_url = \"https://x/rpc\"\n").unwrap(); + save_api_key(&path, "new-key").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert_eq!(cfg.api.key.as_deref(), Some("new-key")); + assert_eq!(cfg.rpc.endpoint_url.as_deref(), Some("https://x/rpc")); + } + #[test] fn output_section_is_optional() { let dir = tempdir().unwrap(); diff --git a/src/context.rs b/src/context.rs index 2974684..4d460c7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,8 +5,8 @@ use std::io::IsTerminal; use quicknode_sdk::{ - AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, SqlConfig, StreamsConfig, - WebhooksConfig, + AdminConfig, CachedToken, HttpConfig, KvStoreConfig, QuicknodeSdk, RpcConfig, SdkFullConfig, + SqlConfig, StreamsConfig, WebhooksConfig, }; use crate::config; @@ -34,6 +34,11 @@ pub struct GlobalArgs { /// `Default` yields 0 (no retries) — the CLI default of 3 comes from clap. pub retries: u32, pub base_url: Option, + /// Optional path prefix inserted between the host and each sub-client's + /// fixed suffix (e.g. `/console-api`). Requires `base_url`. Useful for + /// reverse-proxy / gateway environments and local servers that mount the + /// API under a prefix. + pub base_prefix: Option, } impl GlobalArgs { @@ -166,6 +171,27 @@ impl Ctx { /// `CliError::NoApiKey` — regular commands do not prompt; the user is /// directed to `qn auth login`. pub fn from_global(global: GlobalArgs) -> Result { + Self::build(global, None, None).map(|(ctx, _)| ctx) + } + + /// Like [`from_global`](Self::from_global) but for `qn rpc`: seeds the RPC + /// client's token cache with `seed` (a JWT loaded from disk) and sets the + /// client-wide custom endpoint URL from `config_endpoint_url` (the + /// `[rpc] endpoint_url` config default). Also returns the resolved API key + /// so the caller can scope and write back the token cache. + pub fn from_global_with_rpc_seed( + global: GlobalArgs, + seed: Option, + config_endpoint_url: Option, + ) -> Result<(Self, String), CliError> { + Self::build(global, seed, config_endpoint_url) + } + + fn build( + global: GlobalArgs, + rpc_seed: Option, + rpc_endpoint_url: Option, + ) -> Result<(Self, String), CliError> { let config_path = global.resolve_config_path(); let stdout_is_tty = std::io::stdout().is_terminal(); let (format, wide) = global.resolve_output(stdout_is_tty); @@ -177,14 +203,47 @@ impl Ctx { || unreachable!("prompt disabled for non-auth commands"), )?; - let mut full = sdk_config(api_key); + let mut full = sdk_config(api_key.clone()); + + // The `[rpc] endpoint_url` config default becomes the client-wide custom + // URL (a per-call `--endpoint-url` overrides it in the call itself). We + // validate it here so a malformed config value fails with a clear error + // rather than at call time. `seed` and `endpoint_url` coexist harmlessly: + // the SDK ignores the seed when a custom URL is set. + let rpc_endpoint_url = match rpc_endpoint_url { + Some(u) => Some(validate_endpoint_url(&u)?), + None => None, + }; + if rpc_seed.is_some() || rpc_endpoint_url.is_some() { + full.rpc = Some(RpcConfig { + seed: rpc_seed, + endpoint_url: rpc_endpoint_url, + ..Default::default() + }); + } + + // --base-prefix only makes sense when overriding the host. Composing it + // against the default prod host isn't supported, so fail loudly rather + // than silently ignore it. + if global.base_prefix.is_some() && global.base_url.is_none() { + return Err(CliError::Arg( + "--base-prefix requires --base-url".to_string(), + )); + } // --base-url applies to every sub-client. Useful for wiremock tests and - // on-prem mirrors. Each sub-client has its own base path under the host - // so we suffix correctly. + // on-prem mirrors. Each sub-client has its own fixed suffix; an optional + // --base-prefix is inserted between the host and that suffix for + // reverse-proxy / gateway environments. Tooling Access / RPC minting + // lives on the admin `v0` base, so no separate RPC base is needed here. if let Some(base) = &global.base_url { - let trimmed = validate_base_url(base)?; - apply_base_url(&mut full, trimmed.as_str()); + let host = validate_base_url(base)?; + let prefix = match &global.base_prefix { + Some(p) => validate_base_prefix(p)?, + None => String::new(), + }; + let root = format!("{host}{prefix}"); + apply_base_url(&mut full, &root); } let sdk = QuicknodeSdk::new(&full)?; @@ -199,7 +258,7 @@ impl Ctx { std::env::var("TERM").ok(), ); - Ok(Self { sdk, out, global }) + Ok((Self { sdk, out, global }, api_key)) } } @@ -234,6 +293,55 @@ fn validate_base_url(base: &str) -> Result { Ok(base.trim_end_matches('/').to_string()) } +/// Validates a custom RPC endpoint URL (`--endpoint-url` or `[rpc] endpoint_url`). +/// Unlike [`validate_base_url`], a fully-formed RPC URL carries a path +/// (`https://host/rpc`), so paths are allowed here. We only confirm it parses +/// and uses an http(s) scheme — enough to reject garbage and non-network schemes +/// with a clear CLI error before any call, while leaving the rest to the SDK. +pub(crate) fn validate_endpoint_url(url: &str) -> Result { + let parsed = url::Url::parse(url) + .map_err(|_| CliError::Arg(format!("--endpoint-url '{url}' is not a valid URL")))?; + match parsed.scheme() { + "http" | "https" => Ok(url.to_string()), + other => Err(CliError::Arg(format!( + "--endpoint-url scheme '{other}' is not allowed; use http or https" + ))), + } +} + +/// Validates and normalizes a `--base-prefix` to a leading-slash, no-trailing- +/// slash path fragment (e.g. `/console-api`). Rejects anything that smuggles in +/// a host or query so it can only ever extend the path of `--base-url`: no +/// scheme/authority (`//`), no `?`/`#`, no `.`/`..` traversal segments. +fn validate_base_prefix(prefix: &str) -> Result { + let trimmed = prefix.trim(); + if trimmed.is_empty() { + return Ok(String::new()); + } + if trimmed.contains("//") { + return Err(CliError::Arg( + "--base-prefix must be a path, not a URL (no '//')".into(), + )); + } + if trimmed.contains(['?', '#', '\\']) { + return Err(CliError::Arg( + "--base-prefix must not contain a query string, fragment, or backslash".into(), + )); + } + let inner = trimmed.trim_matches('/'); + if inner.is_empty() { + // Bare "/" (or "///") carries no prefix. + return Ok(String::new()); + } + let normalized = format!("/{inner}"); + if normalized.split('/').any(|seg| matches!(seg, "." | "..")) { + return Err(CliError::Arg( + "--base-prefix must not contain '.' or '..' path segments".into(), + )); + } + Ok(normalized) +} + #[cfg(test)] mod tests { use super::*; @@ -328,6 +436,56 @@ mod tests { assert!(validate_base_url("").is_err()); } + #[test] + fn endpoint_url_allows_http_https_with_path() { + assert_eq!( + validate_endpoint_url("https://my-endpoint.example/rpc").unwrap(), + "https://my-endpoint.example/rpc" + ); + assert_eq!( + validate_endpoint_url("http://127.0.0.1:8080/some/path?x=1").unwrap(), + "http://127.0.0.1:8080/some/path?x=1" + ); + } + + #[test] + fn endpoint_url_rejects_non_http_schemes_and_garbage() { + for bad in ["ftp://x/rpc", "file:///etc/passwd", "not a url", ""] { + assert!(validate_endpoint_url(bad).is_err(), "should reject {bad}"); + } + } + + #[test] + fn base_prefix_normalizes_slashes() { + assert_eq!( + validate_base_prefix("/console-api").unwrap(), + "/console-api" + ); + assert_eq!(validate_base_prefix("console-api").unwrap(), "/console-api"); + assert_eq!( + validate_base_prefix("/console-api/").unwrap(), + "/console-api" + ); + assert_eq!(validate_base_prefix("/a/b").unwrap(), "/a/b"); + } + + #[test] + fn base_prefix_empty_is_empty() { + assert_eq!(validate_base_prefix("").unwrap(), ""); + assert_eq!(validate_base_prefix(" ").unwrap(), ""); + assert_eq!(validate_base_prefix("/").unwrap(), ""); + } + + #[test] + fn base_prefix_rejects_url_like_and_traversal() { + assert!(validate_base_prefix("//evil.com").is_err()); + assert!(validate_base_prefix("http://evil.com").is_err()); + assert!(validate_base_prefix("/a?b=1").is_err()); + assert!(validate_base_prefix("/a#frag").is_err()); + assert!(validate_base_prefix("/../etc").is_err()); + assert!(validate_base_prefix("/a/../b").is_err()); + } + #[test] fn user_agent_identifies_the_cli() { let ua = user_agent(); diff --git a/src/errors.rs b/src/errors.rs index 2e49668..66af44d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -88,15 +88,23 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin CliError::Sdk(SdkError::Api { status, body }) => { render_api_error(status.as_u16(), body, verbose, argv) } - CliError::Sdk(sdk @ SdkError::Http(_)) => { + CliError::Sdk(sdk @ SdkError::Http(inner)) => { + // The failed host varies: control-plane calls hit api.quicknode.com, + // RPC data-plane calls hit the endpoint host (*.quiknode.pro). Name + // the actual host from the reqwest error's URL when available rather + // than hardcoding one. + let host = inner.url().and_then(|u| u.host_str()).map(str::to_string); + let target = host + .map(|h| format!("'{h}'")) + .unwrap_or_else(|| "the Quicknode API".to_string()); let msg = match sdk.http_kind() { Some(HttpKind::Timeout) => { - "request timed out. Check your connection and try again." + format!("request to {target} timed out. Check your connection and try again.") } Some(HttpKind::Connect) => { - "could not connect to api.quicknode.com. Check your network." + format!("could not connect to {target}. Check your network.") } - _ => "HTTP transport failure talking to the Quicknode API.", + _ => format!("HTTP transport failure talking to {target}."), }; if verbose { format!("Error: {msg}\n{sdk}") diff --git a/tests/rpc.rs b/tests/rpc.rs new file mode 100644 index 0000000..6d1ccb7 --- /dev/null +++ b/tests/rpc.rs @@ -0,0 +1,933 @@ +//! Integration tests for `qn rpc`. +//! +//! The in-process harness injects `--api-key test`; the token cache is keyed by +//! the SHA-256 of that key. Tests that exercise the cache pass `--config-file` +//! so `tokens.toml` lands in a tempdir rather than the real home. + +mod common; + +use common::run_qn; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::matchers::{body_partial_json, method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +// SHA-256 of "test" (the harness-injected API key). +const TEST_KEY_HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + +// A far-future ISO timestamp so a freshly minted token is never near expiry. +const FUTURE_ISO: &str = "2099-01-01T00:00:00.000Z"; +// Matching far-future unix seconds for a seeded token. +const FUTURE_UNIX: i64 = 4_070_908_800; + +fn write_token_cache(dir: &tempfile::TempDir, endpoint_url: &str, key_hash: &str) { + let path = dir.path().join("tokens.toml"); + let body = format!( + "[token]\nkey_hash = \"{key_hash}\"\nendpoint_url = \"{endpoint_url}\"\n\ + token = \"seeded.jwt\"\nexp_unix = {FUTURE_UNIX}\n" + ); + std::fs::write(&path, body).unwrap(); +} + +fn cfg_path(dir: &tempfile::TempDir) -> String { + // Provide an API key via the config file so the cache parent dir is the + // tempdir. The harness still injects --api-key test, which wins, so the + // cache key_hash matches TEST_KEY_HASH. + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[api]\nkey = \"test\"\n").unwrap(); + path.to_str().unwrap().to_string() +} + +#[tokio::test] +async fn seeded_token_skips_mint() { + let server = MockServer::start().await; + // RPC endpoint returns a result. The mint route is intentionally NOT + // mounted: if the SDK tried to mint, the call would 404 and fail. + Mock::given(method("POST")) + .and(path("/rpc")) + .and(body_partial_json(json!({ "method": "eth_blockNumber" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &["--config-file", &cfg, "rpc", "call", "eth_blockNumber"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// An already-enabled call must NOT incur the post-enable provisioning wait, +// even with --yes. --yes only matters if Tooling Access is disabled; here the +// seeded token works on the first attempt, so the ~1s wait must not fire. +#[tokio::test] +async fn already_enabled_yes_does_not_wait() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + + let started = std::time::Instant::now(); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--yes", + ], + ) + .await; + let elapsed = started.elapsed(); + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + // The post-enable initial wait is 1s; a happy-path call must be far faster. + assert!( + elapsed < std::time::Duration::from_millis(500), + "already-enabled --yes should not wait, took {elapsed:?}" + ); +} + +#[tokio::test] +async fn no_cache_mints_then_calls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/tooling-access/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "endpoint_url": format!("{}/rpc", server.uri()), + "token": "minted.jwt", + "expires_at": FUTURE_ISO + }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xabc" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let out = run_qn( + &server.uri(), + &["--config-file", &cfg, "rpc", "call", "eth_blockNumber"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // The minted token should have been written back to the cache. + let cached = std::fs::read_to_string(dir.path().join("tokens.toml")).unwrap(); + assert!(cached.contains("minted.jwt"), "cache: {cached}"); +} + +#[tokio::test] +async fn json_rpc_error_exits_nonzero() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "error": { "code": -32602, "message": "invalid params" } + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_getBalance", + "[\"bad\"]", + ], + ) + .await; + // SdkError::Rpc is neither Api nor Http → generic exit 1. + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn not_enabled_without_yes_is_actionable_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v0/tooling-access/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "data": null, + "error": "Tooling access is not enabled. Enable it first." + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + // No --yes, and the harness sets --no-input, so we can't prompt → the + // command should fail with the actionable "run 'qn tooling-access enable'". + let out = run_qn( + &server.uri(), + &["--config-file", &cfg, "rpc", "call", "eth_blockNumber"], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("tooling-access enable"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn not_enabled_with_yes_auto_enables_and_retries() { + let server = MockServer::start().await; + + // Mint: first call 400 (not enabled), subsequent calls succeed. + struct MintSeq { + calls: AtomicUsize, + url: String, + } + impl Respond for MintSeq { + fn respond(&self, _: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(400).set_body_json(json!({ + "data": null, + "error": "Tooling access is not enabled. Enable it first." + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "data": { "endpoint_url": self.url, "token": "minted.jwt", "expires_at": FUTURE_ISO }, + "error": null + })) + } + } + } + + Mock::given(method("POST")) + .and(path("/v0/tooling-access/token")) + .respond_with(MintSeq { + calls: AtomicUsize::new(0), + url: format!("{}/rpc", server.uri()), + }) + .mount(&server) + .await; + // enable_tooling_access PATCH. + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": format!("{}/rpc", server.uri()) }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn account_switch_invalidates_cached_token() { + let server = MockServer::start().await; + // A cache entry scoped to a DIFFERENT key. The SDK must ignore it and mint. + Mock::given(method("POST")) + .and(path("/v0/tooling-access/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "endpoint_url": format!("{}/rpc", server.uri()), + "token": "minted.jwt", + "expires_at": FUTURE_ISO + }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + // Cache scoped to some other account's key hash. + write_token_cache(&dir, &format!("{}/rpc", server.uri()), "deadbeef"); + + let out = run_qn( + &server.uri(), + &["--config-file", &cfg, "rpc", "call", "eth_blockNumber"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// A cached token pointing at an unreachable (disabled) endpoint yields a +// connect failure; the CLI probes status, sees disabled, clears the stale +// token, and routes into the same enable flow as the mint-400 path. In the +// harness (non-TTY + --no-input, no --yes) that flow can't prompt, so it ends +// in the actionable "run qn tooling-access enable" error. +#[tokio::test] +async fn connect_failure_with_disabled_status_prompts_to_enable() { + let server = MockServer::start().await; + // Status probe reports disabled. + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": false, "endpoint_url": null, "endpoint_id": 3 }, + "error": null + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + // Seed a still-valid token whose endpoint_url refuses connections fast + // (loopback, almost-certainly-closed high port) so the RPC POST returns a + // connect error promptly rather than waiting out the timeout. + write_token_cache(&dir, "http://127.0.0.1:9/rpc", TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--retries", + "0", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "should fail"); + // Same actionable message as the mint-400 path (both converge on enable). + assert!( + out.stderr.contains("tooling-access enable"), + "expected enable guidance, got: {}", + out.stderr + ); + // The stale token cache should have been cleared before the enable attempt. + assert!( + !dir.path().join("tokens.toml").exists(), + "stale token cache should be cleared" + ); +} + +// With --yes, the connect-failure-disabled path auto-enables and retries the +// call against the (now re-enabled) endpoint, minting a fresh token. +#[tokio::test] +async fn connect_failure_with_disabled_status_auto_enables_with_yes() { + let server = MockServer::start().await; + // Status reports disabled (drives the connect-failure path into enable). + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": false, "endpoint_url": null, "endpoint_id": 3 }, + "error": null + }))) + .mount(&server) + .await; + // enable() PATCH succeeds. + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": format!("{}/rpc", server.uri()), "endpoint_id": 3 }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + // After enabling, the retry re-mints (the stale token was cleared) and the + // fresh token points at the reachable /rpc mock. + Mock::given(method("POST")) + .and(path("/v0/tooling-access/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "endpoint_url": format!("{}/rpc", server.uri()), "token": "minted.jwt", "expires_at": FUTURE_ISO }, + "error": null + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, "http://127.0.0.1:9/rpc", TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--retries", + "0", + "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// --network: status returns an id, get_endpoint_urls returns the per-network +// map, and the RPC call routes to the mapped (solana) URL, not the default. +#[tokio::test] +async fn network_routes_to_mapped_url() { + let server = MockServer::start().await; + let solana_url = format!("{}/solana", server.uri()); + + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 }, + "error": null + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/3/urls")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "http_url": format!("{}/default", server.uri()), + "wss_url": null, + "multichain_urls": { + "solana-mainnet": { "http_url": solana_url, "wss_url": null } + } + }, + "error": null + }))) + .mount(&server) + .await; + // The call must hit /solana. /default is not mounted, so a misroute 404s. + Mock::given(method("POST")) + .and(path("/solana")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "12345" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "getSlot", + "--network", + "solana-mainnet", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // networks.toml was written for reuse. + let cached = std::fs::read_to_string(dir.path().join("networks.toml")).unwrap(); + assert!(cached.contains("solana-mainnet"), "cache: {cached}"); +} + +// --list-networks prints the available keys without making an RPC call. +#[tokio::test] +async fn list_networks_prints_keys() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 }, + "error": null + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/3/urls")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "http_url": format!("{}/default", server.uri()), + "multichain_urls": { + "solana-mainnet": { "http_url": "https://x/sol", "wss_url": null }, + "polygon": { "http_url": "https://x/matic", "wss_url": null } + } + }, + "error": null + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &["--config-file", &cfg, "rpc", "list-networks"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// Unknown --network surfaces a CLI error that points at `qn rpc list-networks` +// (the SDK's raw "unknown network" message is intercepted and re-rendered). +#[tokio::test] +async fn unknown_network_errors() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 }, + "error": null + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v0/endpoints/3/urls")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "http_url": format!("{}/default", server.uri()), + "multichain_urls": { + "solana-mainnet": { "http_url": "https://x/sol", "wss_url": null } + } + }, + "error": null + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "getSlot", + "--network", + "nope-mainnet", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "should fail on unknown network"); + // The CLI-rendered message keeps the available-keys list and points the + // user at the discovery command, dropping the SDK-internal seeding hint. + assert!( + out.stderr.contains("qn rpc list-networks"), + "expected list-networks hint, got: {}", + out.stderr + ); + assert!( + out.stderr.contains("solana-mainnet"), + "expected available keys, got: {}", + out.stderr + ); +} + +// A custom --endpoint-url sends the call straight to that URL. No token is +// minted (the mint route is not mounted, so a stray mint would 404) and the +// call succeeds against the custom host. +#[tokio::test] +async fn endpoint_url_flag_routes_call_without_minting() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/custom/rpc")) + .and(body_partial_json(json!({ "method": "eth_blockNumber" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xcafe" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let url = format!("{}/custom/rpc", server.uri()); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--endpoint-url", + &url, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // Custom-URL lane never mints, so nothing is written to the token cache. + assert!( + !dir.path().join("tokens.toml").exists(), + "custom-URL call must not write a token cache" + ); +} + +// `[rpc] endpoint_url` in the config file routes a call there with no flag. +#[tokio::test] +async fn config_endpoint_url_routes_call() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/from-config/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let url = format!("{}/from-config/rpc", server.uri()); + std::fs::write( + &path, + format!("[api]\nkey = \"test\"\n\n[rpc]\nendpoint_url = \"{url}\"\n"), + ) + .unwrap(); + let cfg = path.to_str().unwrap(); + + let out = run_qn( + &server.uri(), + &["--config-file", cfg, "rpc", "call", "eth_blockNumber"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// A per-call --endpoint-url overrides the config-file default. +#[tokio::test] +async fn flag_endpoint_url_overrides_config() { + let server = MockServer::start().await; + // Only the flag's URL is mounted; the config URL would 404 if used. + Mock::given(method("POST")) + .and(path("/flag/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let cfg_url = format!("{}/config/rpc", server.uri()); + std::fs::write( + &path, + format!("[api]\nkey = \"test\"\n\n[rpc]\nendpoint_url = \"{cfg_url}\"\n"), + ) + .unwrap(); + let cfg = path.to_str().unwrap(); + let flag_url = format!("{}/flag/rpc", server.uri()); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + cfg, + "rpc", + "call", + "eth_blockNumber", + "--endpoint-url", + &flag_url, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// A failing custom-URL call surfaces the error directly: it must NOT probe +// Tooling Access status or attempt to enable it. Both admin endpoints assert +// zero hits. +#[tokio::test] +async fn custom_url_failure_bypasses_enable_recovery() { + let server = MockServer::start().await; + // The custom endpoint returns a JSON-RPC error (a real, non-transport failure). + Mock::given(method("POST")) + .and(path("/custom/rpc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "error": { "code": -32601, "message": "method not found" } + }))) + .mount(&server) + .await; + // The Tooling Access status probe and enable must never be reached. + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let url = format!("{}/custom/rpc", server.uri()); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--endpoint-url", + &url, + ], + ) + .await; + assert_ne!(out.exit_code, 0, "custom-URL JSON-RPC error should fail"); +} + +// A malformed --endpoint-url is rejected before any request is made. +#[tokio::test] +async fn endpoint_url_rejects_bad_scheme() { + let server = MockServer::start().await; + // Nothing should be requested. + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--endpoint-url", + "ftp://example.com/rpc", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "bad scheme should be rejected"); + assert!( + out.stderr.contains("http or https"), + "stderr={}", + out.stderr + ); +} + +// --endpoint-url and --network are mutually exclusive at the clap layer; the +// parse error fires before any request. +#[tokio::test] +async fn endpoint_url_conflicts_with_network() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--endpoint-url", + "https://x/rpc", + "--network", + "solana-mainnet", + ], + ) + .await; + assert_ne!(out.exit_code, 0, "conflicting flags should fail to parse"); +} + +// --params-file reads the JSON params from a file; the parsed params reach the +// request body just like an inline positional value would. +#[tokio::test] +async fn params_file_reads_json_from_file() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .and(body_partial_json(json!({ + "method": "eth_getBalance", + "params": ["0xabc", "latest"] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + let params_path = dir.path().join("params.json"); + std::fs::write(¶ms_path, r#"["0xabc", "latest"]"#).unwrap(); + let params_file = params_path.to_str().unwrap(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_getBalance", + "--params-file", + params_file, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// A missing --params-file path fails with an actionable error before any call. +#[tokio::test] +async fn params_file_missing_path_errors() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + let missing = dir.path().join("does-not-exist.json"); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_getBalance", + "-f", + missing.to_str().unwrap(), + ], + ) + .await; + assert_ne!(out.exit_code, 0, "missing params file should fail"); + assert!( + out.stderr.contains("could not read params file"), + "stderr={}", + out.stderr + ); +} + +// The positional params and --params-file are mutually exclusive at the clap +// layer; passing both fails to parse before any request. +#[tokio::test] +async fn params_positional_and_file_conflict() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_path(&dir); + write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + let params_path = dir.path().join("params.json"); + std::fs::write(¶ms_path, "[]").unwrap(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_getBalance", + "[\"inline\"]", + "--params-file", + params_path.to_str().unwrap(), + ], + ) + .await; + assert_ne!( + out.exit_code, 0, + "positional + --params-file should conflict" + ); +} diff --git a/tests/tooling_access.rs b/tests/tooling_access.rs new file mode 100644 index 0000000..ccb588b --- /dev/null +++ b/tests/tooling_access.rs @@ -0,0 +1,98 @@ +//! Integration tests for `qn tooling-access {status,enable,disable}`. + +mod common; + +use common::run_qn; +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn status_returns_enabled() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "enabled": true, + "endpoint_url": "https://tooling-access-abc123.quiknode.pro", + "enabled_at": "2026-06-23T20:30:00.000Z" + }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["tooling-access", "status"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn enable_sends_enabled_true() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .and(body_json(json!({ "enabled": true }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": true, "endpoint_url": "https://x.quiknode.pro" }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["tooling-access", "enable"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn disable_with_yes_sends_enabled_false() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .and(body_json(json!({ "enabled": false }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "enabled": false }, + "error": null + }))) + .expect(1) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["tooling-access", "disable", "--yes"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn disable_without_yes_needs_confirmation_and_makes_no_request() { + let server = MockServer::start().await; + // A non-TTY without --yes must not reach the control plane. + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["tooling-access", "disable"]).await; + // NeedsConfirmation → exit 5. + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn enable_surfaces_ineligible_plan_error() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "data": null, + "error": "The legacy billing plan for your account doesn't support tooling access." + }))) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["tooling-access", "enable"]).await; + // Api error → exit 2. + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +}