Skip to main content

quicknode_sdk/admin/
tooling_access.rs

1//! Tooling Access control plane.
2//!
3//! Tooling Access provisions a single multichain, read-only endpoint per
4//! account and mints short-lived ES256 session JWTs. Those JWTs are consumed by
5//! the [`crate::rpc::RpcApiClient`] to authenticate RPC calls directly against
6//! the provisioned endpoint; the private signing key never leaves the server.
7//!
8//! These routes live on the same Admin API base URL as the rest of this client.
9//! Note this is distinct from [`super::AdminApiClient::create_jwt`], which
10//! registers a public key on an endpoint's security config — here we mint the
11//! session tokens themselves.
12
13use serde::Deserialize;
14
15use crate::{config::CachedToken, errors::SdkError};
16
17use super::AdminApiClient;
18
19/// Current Tooling Access status for the account. `enabled` is the source of
20/// truth — a previously-provisioned-but-disabled account may still report a
21/// non-null `endpoint_url`.
22#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass)]
23#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
24#[cfg_attr(feature = "node", napi_derive::napi(object))]
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct ToolingAccessStatus {
27    pub enabled: bool,
28    pub endpoint_url: Option<String>,
29    pub enabled_at: Option<String>,
30    /// The provisioned endpoint's id. Used to fetch the per-network URL map
31    /// (`get_endpoint_urls`) for multichain routing. `None` on control planes
32    /// that don't yet return it.
33    pub endpoint_id: Option<String>,
34}
35
36// Control-plane responses use the `{ data, error }` envelope. `data` is null on
37// error and `error` carries the message; success carries the payload in `data`.
38#[derive(Deserialize)]
39struct Envelope<T> {
40    data: Option<T>,
41    error: Option<String>,
42}
43
44#[derive(Deserialize)]
45struct StatusData {
46    enabled: bool,
47    #[serde(default)]
48    endpoint_url: Option<String>,
49    #[serde(default)]
50    enabled_at: Option<String>,
51    // The endpoint id. Serde may receive it as a string or a number depending
52    // on the control plane; deserialize_optional_id normalizes both to String.
53    #[serde(default, deserialize_with = "deserialize_optional_id")]
54    endpoint_id: Option<String>,
55}
56
57#[derive(Deserialize)]
58struct TokenData {
59    endpoint_url: String,
60    token: String,
61    expires_at: String,
62}
63
64impl AdminApiClient {
65    /// Returns the current Tooling Access status. Always succeeds (when
66    /// authorized); inspect `enabled` to decide whether to enable.
67    pub async fn tooling_access_status(&self) -> Result<ToolingAccessStatus, SdkError> {
68        let url = self.config.admin().base_url.join("tooling-access")?;
69        let resp = self
70            .config
71            .http_client()
72            .get(url)
73            .send()
74            .await
75            .map_err(SdkError::Http)?;
76        Self::parse_status(resp).await
77    }
78
79    /// Enables (provisions) Tooling Access. Idempotent — safe to call when
80    /// already enabled. Requires an admin role and an eligible plan; ineligible
81    /// callers receive an [`SdkError::Api`] carrying the reason.
82    pub async fn enable_tooling_access(&self) -> Result<ToolingAccessStatus, SdkError> {
83        self.set_tooling_access_enabled(true).await
84    }
85
86    /// Disables Tooling Access, pausing the endpoint. Idempotent.
87    pub async fn disable_tooling_access(&self) -> Result<ToolingAccessStatus, SdkError> {
88        self.set_tooling_access_enabled(false).await
89    }
90
91    async fn set_tooling_access_enabled(
92        &self,
93        enabled: bool,
94    ) -> Result<ToolingAccessStatus, SdkError> {
95        let url = self.config.admin().base_url.join("tooling-access")?;
96        let resp = self
97            .config
98            .http_client()
99            .patch(url)
100            .json(&serde_json::json!({ "enabled": enabled }))
101            .send()
102            .await
103            .map_err(SdkError::Http)?;
104        Self::parse_status(resp).await
105    }
106
107    /// Mints a short-lived session JWT for the provisioned endpoint. Returns the
108    /// endpoint URL, the JWT, and its expiry as a [`CachedToken`]. Requires
109    /// Tooling Access to be enabled first; otherwise returns an
110    /// [`SdkError::Api`] with status 400.
111    pub async fn mint_tooling_token(&self) -> Result<CachedToken, SdkError> {
112        let url = self.config.admin().base_url.join("tooling-access/token")?;
113        let resp = self
114            .config
115            .http_client()
116            .post(url)
117            .send()
118            .await
119            .map_err(SdkError::Http)?;
120
121        let status = resp.status();
122        let body = resp.text().await.map_err(SdkError::Http)?;
123        if !status.is_success() {
124            return Err(SdkError::Api { status, body });
125        }
126        let env: Envelope<TokenData> =
127            serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
128        let data = env.data.ok_or_else(|| SdkError::Api {
129            status,
130            body: env
131                .error
132                .unwrap_or_else(|| "missing token data".to_string()),
133        })?;
134        let exp_unix = parse_rfc3339_to_unix(&data.expires_at)?;
135        Ok(CachedToken {
136            endpoint_url: data.endpoint_url,
137            token: data.token,
138            exp_unix,
139        })
140    }
141
142    async fn parse_status(resp: reqwest::Response) -> Result<ToolingAccessStatus, SdkError> {
143        let status = resp.status();
144        let body = resp.text().await.map_err(SdkError::Http)?;
145        if !status.is_success() {
146            return Err(SdkError::Api { status, body });
147        }
148        let env: Envelope<StatusData> =
149            serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
150        let data = env.data.ok_or_else(|| SdkError::Api {
151            status,
152            body: env
153                .error
154                .unwrap_or_else(|| "missing status data".to_string()),
155        })?;
156        Ok(ToolingAccessStatus {
157            enabled: data.enabled,
158            endpoint_url: data.endpoint_url,
159            enabled_at: data.enabled_at,
160            endpoint_id: data.endpoint_id,
161        })
162    }
163}
164
165// The endpoint id arrives as either a JSON string or a number. Accept both and
166// normalize to an owned String so the field is uniform regardless of source.
167fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
168where
169    D: serde::Deserializer<'de>,
170{
171    use serde::Deserialize;
172    #[derive(Deserialize)]
173    #[serde(untagged)]
174    enum StringOrNum {
175        Str(String),
176        Num(i64),
177    }
178    let opt = Option::<StringOrNum>::deserialize(deserializer)?;
179    Ok(opt.map(|v| match v {
180        StringOrNum::Str(s) => s,
181        StringOrNum::Num(n) => n.to_string(),
182    }))
183}
184
185// Parse an RFC3339 / ISO8601 timestamp (e.g. "2026-06-23T20:40:00.000Z") to
186// unix seconds without pulling in a date crate. Handles the common `Z` (UTC)
187// and `±HH:MM` offset forms. The control plane emits UTC `Z` timestamps.
188pub(crate) fn parse_rfc3339_to_unix(s: &str) -> Result<i64, SdkError> {
189    let bad = || SdkError::Decode {
190        // Reuse Decode for malformed control-plane payloads; build a synthetic
191        // serde error so the variant carries a useful message and the raw body.
192        source: serde::de::Error::custom("invalid expires_at timestamp"),
193        body: s.to_string(),
194    };
195
196    let (date, rest) = s.split_once('T').ok_or_else(bad)?;
197    let mut date_parts = date.split('-');
198    let y: i64 = date_parts
199        .next()
200        .and_then(|v| v.parse().ok())
201        .ok_or_else(bad)?;
202    let mo: i64 = date_parts
203        .next()
204        .and_then(|v| v.parse().ok())
205        .ok_or_else(bad)?;
206    let d: i64 = date_parts
207        .next()
208        .and_then(|v| v.parse().ok())
209        .ok_or_else(bad)?;
210
211    // Strip the timezone designator, capturing the offset in seconds.
212    let (time_part, offset_secs) = if let Some(t) = rest.strip_suffix('Z') {
213        (t, 0i64)
214    } else if let Some(idx) = rest.rfind(['+', '-']) {
215        let (t, tz) = rest.split_at(idx);
216        let sign = if tz.starts_with('-') { -1 } else { 1 };
217        let tz = &tz[1..];
218        let (oh, om) = tz.split_once(':').ok_or_else(bad)?;
219        let oh: i64 = oh.parse().map_err(|_| bad())?;
220        let om: i64 = om.parse().map_err(|_| bad())?;
221        (t, sign * (oh * 3600 + om * 60))
222    } else {
223        (rest, 0i64)
224    };
225
226    // Time may carry fractional seconds; drop them.
227    let time_main = time_part.split('.').next().unwrap_or(time_part);
228    let mut tparts = time_main.split(':');
229    let hh: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?;
230    let mm: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?;
231    let ss: i64 = tparts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
232
233    let days = days_from_civil(y, mo, d);
234    let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset_secs;
235    Ok(secs)
236}
237
238// Days from 1970-01-01 for a proleptic Gregorian calendar date (Howard
239// Hinnant's civil-date algorithm).
240fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
241    let y = if m <= 2 { y - 1 } else { y };
242    let era = if y >= 0 { y } else { y - 399 } / 400;
243    let yoe = y - era * 400;
244    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
245    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
246    era * 146_097 + doe - 719_468
247}
248
249#[cfg(test)]
250#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
251mod tests {
252    use super::*;
253    use crate::config::{AdminConfig, SdkFullConfig};
254    use crate::QuicknodeSdk;
255    use wiremock::matchers::{method, path};
256    use wiremock::{Mock, MockServer, ResponseTemplate};
257
258    fn sdk_for(base: &str) -> QuicknodeSdk {
259        let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
260        cfg.admin = Some(AdminConfig {
261            base_url: Some(format!("{base}/")),
262        });
263        QuicknodeSdk::new(&cfg).unwrap()
264    }
265
266    #[test]
267    fn parses_utc_z_timestamp() {
268        // 2026-06-23T20:40:00Z is 1782247200 unix seconds.
269        let got = parse_rfc3339_to_unix("2026-06-23T20:40:00.000Z").unwrap();
270        assert_eq!(got, 1_782_247_200, "got {got}");
271    }
272
273    #[test]
274    fn epoch_round_trips() {
275        assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:00Z").unwrap(), 0);
276        assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:01Z").unwrap(), 1);
277    }
278
279    #[test]
280    fn applies_positive_offset() {
281        // 00:00 at +01:00 is 23:00 the previous day UTC == -3600.
282        assert_eq!(
283            parse_rfc3339_to_unix("1970-01-01T00:00:00+01:00").unwrap(),
284            -3600
285        );
286    }
287
288    #[test]
289    fn rejects_garbage_timestamp() {
290        assert!(matches!(
291            parse_rfc3339_to_unix("not-a-date"),
292            Err(SdkError::Decode { .. })
293        ));
294    }
295
296    #[tokio::test]
297    async fn status_happy_path() {
298        let server = MockServer::start().await;
299        Mock::given(method("GET"))
300            .and(path("/tooling-access"))
301            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
302                "data": {
303                    "enabled": true,
304                    // endpoint_id is a JSON number on the wire; it must
305                    // deserialize into the String endpoint_id field.
306                    "endpoint_id": 3,
307                    "endpoint_url": "https://tooling-access-abc123.quiknode.pro",
308                    "enabled_at": "2026-06-23T20:30:00.000Z"
309                },
310                "error": null
311            })))
312            .mount(&server)
313            .await;
314
315        let sdk = sdk_for(&server.uri());
316        let status = sdk.admin.tooling_access_status().await.unwrap();
317        assert!(status.enabled);
318        assert_eq!(
319            status.endpoint_url.as_deref(),
320            Some("https://tooling-access-abc123.quiknode.pro")
321        );
322        assert_eq!(status.endpoint_id.as_deref(), Some("3"));
323    }
324
325    #[tokio::test]
326    async fn enable_returns_status() {
327        let server = MockServer::start().await;
328        Mock::given(method("PATCH"))
329            .and(path("/tooling-access"))
330            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                "data": { "enabled": true, "endpoint_url": "https://x.quiknode.pro" },
332                "error": null
333            })))
334            .mount(&server)
335            .await;
336
337        let sdk = sdk_for(&server.uri());
338        let status = sdk.admin.enable_tooling_access().await.unwrap();
339        assert!(status.enabled);
340    }
341
342    #[tokio::test]
343    async fn mint_token_happy_path() {
344        let server = MockServer::start().await;
345        Mock::given(method("POST"))
346            .and(path("/tooling-access/token"))
347            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
348                "data": {
349                    "endpoint_url": "https://tooling-access-abc123.quiknode.pro",
350                    "token": "header.payload.sig",
351                    "expires_at": "2026-06-23T20:40:00.000Z"
352                },
353                "error": null
354            })))
355            .mount(&server)
356            .await;
357
358        let sdk = sdk_for(&server.uri());
359        let tok = sdk.admin.mint_tooling_token().await.unwrap();
360        assert_eq!(tok.token, "header.payload.sig");
361        assert_eq!(tok.exp_unix, 1_782_247_200);
362    }
363
364    #[tokio::test]
365    async fn mint_token_not_enabled_is_api_error() {
366        let server = MockServer::start().await;
367        Mock::given(method("POST"))
368            .and(path("/tooling-access/token"))
369            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
370                "data": null,
371                "error": "Tooling access is not enabled. Enable it first."
372            })))
373            .mount(&server)
374            .await;
375
376        let sdk = sdk_for(&server.uri());
377        let err = sdk.admin.mint_tooling_token().await.unwrap_err();
378        match err {
379            SdkError::Api { status, body } => {
380                assert_eq!(status.as_u16(), 400);
381                assert!(body.contains("not enabled"), "body: {body}");
382            }
383            other => panic!("expected Api error, got {other:?}"),
384        }
385    }
386}