diff --git a/Cargo.lock b/Cargo.lock index 4251b8a..7a2a555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,6 +1496,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "sha2", "tempfile", "thiserror 1.0.69", "time", @@ -1509,8 +1510,6 @@ dependencies = [ [[package]] name = "quicknode-sdk" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91ad3da8e75511dfbb2ae1d81eb914c70a8187f58369164c56a321cb1a20799" dependencies = [ "config", "reqwest 0.13.4", @@ -1518,6 +1517,7 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "tokio", "url", ] diff --git a/Cargo.toml b/Cargo.toml index ec91e7f..ca652f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } @@ -49,6 +52,12 @@ predicates = "3" insta = { version = "1", features = ["yaml"] } tempfile = "3" +# Local development: build against the working copy of the SDK core crate +# instead of the published quicknode-sdk = "0.3". Remove before committing / +# releasing; bump the dependency version once the SDK changes are published. +[patch.crates-io] +quicknode-sdk = { path = "../sdk/crates/core" } + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/README.md b/README.md index 764c5d9..0aad013 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,39 @@ qn kv list contains allowlist 0xabc qn kv list get allowlist ``` +### On-chain RPC + +Make JSON-RPC calls with no endpoint to provision. `qn rpc` 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 eth_blockNumber +qn rpc eth_getBalance '["0xabc...", "latest"]' +qn rpc eth_call '{"to":"0x..."}' +echo '[...]' | qn rpc eth_call - # read params from stdin + +qn rpc 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 for the endpoint +qn rpc getSlot --network solana-mainnet +qn rpc eth_chainId --network polygon +``` + +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`); `--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 89fac6a..83940b5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -93,6 +93,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, @@ -145,6 +151,13 @@ pub enum Command { /// Manage the Quicknode KV store (sets and lists). Kv(commands::kv::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 @@ -202,6 +215,7 @@ impl Cli { yes_count: self.yes, retries: self.retries, base_url: self.base_url.clone(), + base_prefix: self.base_prefix.clone(), } } @@ -234,6 +248,12 @@ impl Cli { Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await, Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await, Command::Kv(args) => commands::kv::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 e7dc5d7..51db1d3 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -111,6 +111,13 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase enabled-count - `kv` — `set` (put, get, list, delete, bulk) and `list` (list, get, create, append, contains, remove-item, update, delete) +- `tooling-access` — status, enable, disable (provisions the endpoint `rpc` uses) +- `rpc` — make a JSON-RPC call against the account's Tooling Access endpoint; + the session JWT is minted and refreshed automatically. `qn rpc [json-params]` + (params is a JSON array or object, or `-` to read from stdin). 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` lists the available keys. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -165,6 +172,19 @@ 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 eth_blockNumber # → "0x…" (default network) +qn rpc eth_getBalance '["0xabc...", "latest"]' +qn rpc --list-networks # available network keys for this endpoint +qn rpc getSlot --network solana-mainnet +``` + +`qn rpc` mints and refreshes the session JWT for you; the only one-time step is +enabling Tooling Access (or pass `--yes` to `qn rpc` to enable on first use). + ## 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 3d6209b..72668c8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,7 +10,9 @@ pub mod chain; pub mod endpoint; pub mod kv; pub mod metrics; +pub mod rpc; 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..0315b3e --- /dev/null +++ b/src/commands/rpc.rs @@ -0,0 +1,413 @@ +//! `qn rpc [params]` — make a JSON-RPC call against the account's +//! Tooling Access endpoint. +//! +//! 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. + +use std::io::Read; + +use clap::Args as ClapArgs; +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(after_help = "Examples:\n \ + qn rpc eth_blockNumber\n \ + qn rpc eth_getBalance '[\"0xabc...\", \"latest\"]'\n \ + qn rpc getSlot --network solana-mainnet\n \ + qn rpc --list-networks\n \ + echo '[...]' | qn rpc eth_call -")] +pub struct Args { + /// The JSON-RPC method, e.g. `eth_blockNumber`. Omit only with + /// `--list-networks`. + pub method: Option, + + /// JSON params: an array (positional) or object (by-name). Pass `-` to read + /// the JSON from stdin. Omit for no params (sends `[]`). + /// + /// To auto-enable Tooling Access when it isn't provisioned yet, pass the + /// global `--yes`/`-y` flag (required in non-interactive contexts). + pub params: 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, + + /// List the endpoint's available network keys and exit (no RPC call). + #[arg(long, conflicts_with_all = ["params", "network"])] + pub list_networks: bool, +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + let params = parse_params(args.params.as_deref())?; + + if !args.list_networks && args.method.is_none() { + return Err(CliError::Arg( + "missing JSON-RPC method (e.g. 'qn rpc eth_blockNumber'), or pass --list-networks" + .to_string(), + )); + } + + // 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)?; + + // Multichain selection (--network or --list-networks) 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. + if args.list_networks { + let map = ensure_networks(&ctx, networks_path.as_deref()).await?; + return emit_networks(&ctx, &map); + } + 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_deref().unwrap_or_default(); + + // First attempt. Both ways of discovering "Tooling Access is 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()).await { + Ok(v) => v, + 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(e), + }; + + // Snapshot the (possibly refreshed) token and write it back. 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) +} + +/// 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, +) -> Result { + // RPC reads are safe to retry on transient transport failures, same as + // other read-only commands. The SDK handles its own one-shot 401 refresh. + retrying(ctx.global.retries, || { + ctx.sdk.rpc.call(method, params.clone(), network.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 { + match ctx + .sdk + .rpc + .call(method, params.clone(), network.clone()) + .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(()) +} + +/// Parse the params argument: `None` → no params; `Some("-")` → read JSON from +/// stdin; otherwise parse the string as a JSON value. +fn parse_params(arg: Option<&str>) -> Result, CliError> { + let raw = match arg { + None => return Ok(None), + Some("-") => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(CliError::Io)?; + buf + } + Some(s) => s.to_string(), + }; + 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)) +} + +/// 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..8f4bbda --- /dev/null +++ b/src/commands/tooling_access.rs @@ -0,0 +1,86 @@ +//! `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::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, pausing the endpoint. 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 => { + 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 b21db60..9e59635 100644 --- a/src/config.rs +++ b/src/config.rs @@ -196,6 +196,274 @@ pub fn save_api_key(path: &Path, api_key: &str) -> 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) { diff --git a/src/context.rs b/src/context.rs index 1616c71..4d7b8c7 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, StreamsConfig, - WebhooksConfig, + AdminConfig, CachedToken, HttpConfig, KvStoreConfig, QuicknodeSdk, RpcConfig, SdkFullConfig, + 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 { @@ -131,6 +136,20 @@ 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).map(|(ctx, _)| ctx) + } + + /// Like [`from_global`](Self::from_global) but seeds the RPC client's token + /// cache with `seed` (a JWT loaded from disk by `qn rpc`). 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, + ) -> Result<(Self, String), CliError> { + Self::build(global, seed) + } + + fn build(global: GlobalArgs, rpc_seed: 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); @@ -142,25 +161,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()); + + if rpc_seed.is_some() { + full.rpc = Some(RpcConfig { + seed: rpc_seed, + ..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)?; - let trimmed = 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}"); full.admin = Some(AdminConfig { - base_url: Some(format!("{trimmed}/v0/")), + base_url: Some(format!("{root}/v0/")), }); full.streams = Some(StreamsConfig { - base_url: Some(format!("{trimmed}/streams/rest/v1/")), + base_url: Some(format!("{root}/streams/rest/v1/")), }); full.webhooks = Some(WebhooksConfig { - base_url: Some(format!("{trimmed}/webhooks/rest/v1/")), + base_url: Some(format!("{root}/webhooks/rest/v1/")), }); full.kvstore = Some(KvStoreConfig { - base_url: Some(format!("{trimmed}/kv/rest/v1/")), + base_url: Some(format!("{root}/kv/rest/v1/")), }); } @@ -176,7 +217,7 @@ impl Ctx { std::env::var("TERM").ok(), ); - Ok(Self { sdk, out, global }) + Ok((Self { sdk, out, global }, api_key)) } } @@ -211,6 +252,42 @@ fn validate_base_url(base: &str) -> Result { Ok(base.trim_end_matches('/').to_string()) } +/// 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::*; @@ -299,6 +376,34 @@ mod tests { assert!(validate_base_url("").is_err()); } + #[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..bfbf7ea --- /dev/null +++ b/tests/rpc.rs @@ -0,0 +1,530 @@ +//! 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", "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", "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", "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", "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", "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", "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", "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", "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", "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", + "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 an error (the SDK lists valid keys). +#[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", "getSlot", "--network", "nope-mainnet"], + ) + .await; + assert_ne!(out.exit_code, 0, "should fail on unknown network"); + assert!( + out.stderr.contains("unknown network") || out.stderr.contains("nope-mainnet"), + "stderr={}", + out.stderr + ); +} diff --git a/tests/tooling_access.rs b/tests/tooling_access.rs new file mode 100644 index 0000000..9c0eb7a --- /dev/null +++ b/tests/tooling_access.rs @@ -0,0 +1,82 @@ +//! 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_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"]).await; + assert_eq!(out.exit_code, 0, "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); +}