Skip to main content

qualia_client_core/
cloudflare.rs

1//! **Cloudflare API client** — publishes the QDP front-door DNS record (the easy-install path).
2//!
3//! The front door is anchored by a single DNS TXT record at `_qdp.<domain>` (QDP §3.6): a domain owner
4//! adds one record and needs **no server**. For the many domains already on Cloudflare, doing that by hand
5//! is friction; this module drives Cloudflare's v4 API to publish the same record turnkey.
6//!
7//! This is a **convenience path, not infrastructure**: it holds only the user's own Cloudflare API token
8//! (supplied by the principal), talks only to `api.cloudflare.com`, and carries only the public front-door
9//! record produced by [`crate::front_door::FrontDoorRecord::to_dns_txt`] — never a private key (QDP §5).
10//!
11//! The network calls are host-only (`#[cfg(not(target_arch = "wasm32"))]`) and use `reqwest::blocking`
12//! (already a dependency — no new crates). The **pure helpers** (payload shaping, response parsing) carry
13//! the logic and are unit-tested without a network.
14
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17
18use crate::front_door::{dns_record_name, FrontDoorRecord};
19
20/// Base URL for the Cloudflare v4 API.
21#[cfg(not(target_arch = "wasm32"))]
22const CF_API_BASE: &str = "https://api.cloudflare.com/client/v4";
23
24/// Credentials for the user's own Cloudflare account. Supplied by the principal; never persisted here.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CfConfig {
27    /// A scoped Cloudflare API **token** (Bearer), *not* the legacy global API key.
28    pub api_token: String,
29    /// The zone (domain) id the front-door record is published under.
30    pub zone_id: String,
31}
32
33// --- pure helpers (no network) --------------------------------------------------------------------
34
35/// Shape the Cloudflare `POST /zones/{zone}/dns_records` body for a front-door record.
36///
37/// A TXT record at `_qdp.<domain>` whose content is the compact front-door value. TTL 300s (5 min) —
38/// short, because the front door is meant to be updatable quickly.
39pub fn dns_record_payload(rec: &FrontDoorRecord) -> Value {
40    json!({
41        "type": "TXT",
42        "name": dns_record_name(&rec.domain),
43        "content": rec.to_dns_txt(),
44        "ttl": 300,
45    })
46}
47
48/// Parse a Cloudflare `GET /zones` response into `(zone_id, zone_name)` pairs.
49///
50/// Iterates `json["result"]` (the array of zones) and collects each entry's `id` + `name`. Entries
51/// missing either field are skipped rather than failing the whole listing.
52pub fn parse_zone_list(json: &Value) -> Vec<(String, String)> {
53    let Some(arr) = json.get("result").and_then(Value::as_array) else {
54        return Vec::new();
55    };
56    arr.iter()
57        .filter_map(|z| {
58            let id = z.get("id").and_then(Value::as_str)?;
59            let name = z.get("name").and_then(Value::as_str)?;
60            Some((id.to_string(), name.to_string()))
61        })
62        .collect()
63}
64
65/// Validate a Cloudflare `GET /user/tokens/verify` response.
66///
67/// Cloudflare returns `{"success": bool, "result": {"status": "active"}, ...}`. The token is good iff the
68/// call succeeded **and** the token status is `"active"` (a token can verify-successfully but be disabled or
69/// expired, reported via a non-`active` status).
70pub fn parse_verify_token(json: &Value) -> Result<(), String> {
71    let success = json
72        .get("success")
73        .and_then(Value::as_bool)
74        .unwrap_or(false);
75    if !success {
76        return Err(format!("cloudflare token verify unsuccessful: {json}"));
77    }
78    let status = json
79        .get("result")
80        .and_then(|r| r.get("status"))
81        .and_then(Value::as_str)
82        .unwrap_or("");
83    if status == "active" {
84        Ok(())
85    } else {
86        Err(format!("cloudflare token not active (status: {status:?})"))
87    }
88}
89
90// --- host-only network calls ----------------------------------------------------------------------
91
92/// Build a blocking `reqwest` client bearing the token, with a short (8s) timeout.
93#[cfg(not(target_arch = "wasm32"))]
94fn client() -> Result<reqwest::blocking::Client, String> {
95    use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
96    // Note: the actual `Authorization: Bearer <token>` header is attached per-request (below), because the
97    // token is the caller's argument, not client-global. Here we only set the content type default and the
98    // timeout; the token is added on each request builder so the client itself carries no secret.
99    let mut headers = HeaderMap::new();
100    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
101    // AUTHORIZATION intentionally left unset at client level; set per request.
102    let _ = AUTHORIZATION;
103    reqwest::blocking::Client::builder()
104        .timeout(std::time::Duration::from_secs(8))
105        .default_headers(headers)
106        .build()
107        .map_err(|e| format!("cloudflare http client build failed: {e}"))
108}
109
110/// Attach the bearer token + JSON content-type to a request builder.
111#[cfg(not(target_arch = "wasm32"))]
112fn authed(
113    req: reqwest::blocking::RequestBuilder,
114    token: &str,
115) -> reqwest::blocking::RequestBuilder {
116    req.header("Authorization", format!("Bearer {token}"))
117        .header("Content-Type", "application/json")
118}
119
120/// Read a response, mapping transport + non-2xx into a `String` error, else returning the parsed JSON.
121#[cfg(not(target_arch = "wasm32"))]
122fn read_json(resp: reqwest::blocking::Response) -> Result<Value, String> {
123    let status = resp.status();
124    let body = resp
125        .text()
126        .map_err(|e| format!("cloudflare read body failed: {e}"))?;
127    if !status.is_success() {
128        return Err(format!("cloudflare HTTP {status}: {body}"));
129    }
130    serde_json::from_str(&body).map_err(|e| format!("cloudflare JSON parse failed: {e}: {body}"))
131}
132
133/// Verify a Cloudflare API token is valid and active (`GET /user/tokens/verify`).
134#[cfg(not(target_arch = "wasm32"))]
135pub fn verify_token(token: &str) -> Result<(), String> {
136    let url = format!("{CF_API_BASE}/user/tokens/verify");
137    let resp = authed(client()?.get(&url), token)
138        .send()
139        .map_err(|e| format!("cloudflare verify request failed: {e}"))?;
140    let json = read_json(resp)?;
141    parse_verify_token(&json)
142}
143
144/// List the zones (domains) the token can manage (`GET /zones`) → `(zone_id, zone_name)` pairs.
145#[cfg(not(target_arch = "wasm32"))]
146pub fn list_zones(token: &str) -> Result<Vec<(String, String)>, String> {
147    let url = format!("{CF_API_BASE}/zones");
148    let resp = authed(client()?.get(&url), token)
149        .send()
150        .map_err(|e| format!("cloudflare zones request failed: {e}"))?;
151    let json = read_json(resp)?;
152    Ok(parse_zone_list(&json))
153}
154
155/// Publish the front-door TXT record (`POST /zones/{zone}/dns_records`) → the created record's id.
156#[cfg(not(target_arch = "wasm32"))]
157pub fn publish_front_door(cfg: &CfConfig, rec: &FrontDoorRecord) -> Result<String, String> {
158    let url = format!("{CF_API_BASE}/zones/{}/dns_records", cfg.zone_id);
159    let payload = dns_record_payload(rec);
160    let resp = authed(client()?.post(&url), &cfg.api_token)
161        .json(&payload)
162        .send()
163        .map_err(|e| format!("cloudflare publish request failed: {e}"))?;
164    let json = read_json(resp)?;
165    json.get("result")
166        .and_then(|r| r.get("id"))
167        .and_then(Value::as_str)
168        .map(|s| s.to_string())
169        .ok_or_else(|| format!("cloudflare publish returned no result.id: {json}"))
170}
171
172/// Provision a Cloudflare R2 bucket (`POST /accounts/{account_id}/r2/buckets`).
173#[cfg(not(target_arch = "wasm32"))]
174pub fn provision_r2_bucket(token: &str, account_id: &str, bucket_name: &str) -> Result<(), String> {
175    let url = format!("{CF_API_BASE}/accounts/{account_id}/r2/buckets");
176    let payload = json!({ "name": bucket_name });
177    let resp = authed(client()?.post(&url), token)
178        .json(&payload)
179        .send()
180        .map_err(|e| format!("cloudflare r2 provision request failed: {e}"))?;
181
182    // Cloudflare returns 400 with code 10015 if the bucket already exists.
183    let status = resp.status();
184    let body = resp.text().unwrap_or_default();
185    if !status.is_success() && !body.contains("10015") {
186        return Err(format!("cloudflare r2 provision HTTP {status}: {body}"));
187    }
188    Ok(())
189}
190
191/// Provision a Cloudflare Worker script (`PUT /accounts/{account_id}/workers/scripts/{script_name}`).
192#[cfg(not(target_arch = "wasm32"))]
193pub fn provision_worker(
194    token: &str,
195    account_id: &str,
196    script_name: &str,
197    script_content: &str,
198) -> Result<(), String> {
199    let url = format!("{CF_API_BASE}/accounts/{account_id}/workers/scripts/{script_name}");
200
201    // For simple JS workers, we send application/javascript.
202    let mut req = client()?.put(&url);
203    req = req
204        .header("Authorization", format!("Bearer {token}"))
205        .header("Content-Type", "application/javascript");
206
207    let resp = req
208        .body(script_content.to_string())
209        .send()
210        .map_err(|e| format!("cloudflare worker provision request failed: {e}"))?;
211
212    let _json = read_json(resp)?;
213    Ok(())
214}
215
216/// Provision a Cloudflare Tunnel (`POST /accounts/{account_id}/cfd_tunnel`).
217/// Returns the generated Tunnel ID.
218#[cfg(not(target_arch = "wasm32"))]
219pub fn provision_tunnel(
220    token: &str,
221    account_id: &str,
222    tunnel_name: &str,
223    tunnel_secret_b64: &str,
224) -> Result<String, String> {
225    let url = format!("{CF_API_BASE}/accounts/{account_id}/cfd_tunnel");
226    let payload = json!({ "name": tunnel_name, "tunnel_secret": tunnel_secret_b64 });
227    let resp = authed(client()?.post(&url), token)
228        .json(&payload)
229        .send()
230        .map_err(|e| format!("cloudflare tunnel provision request failed: {e}"))?;
231
232    let json = read_json(resp)?;
233    json.get("result")
234        .and_then(|r| r.get("id"))
235        .and_then(Value::as_str)
236        .map(|s| s.to_string())
237        .ok_or_else(|| format!("cloudflare tunnel response missing id: {json}"))
238}
239
240/// Route DNS to a Cloudflare Tunnel (`POST /zones/{zone_id}/dns_records`).
241#[cfg(not(target_arch = "wasm32"))]
242pub fn route_tunnel_dns(
243    token: &str,
244    zone_id: &str,
245    record_name: &str,
246    tunnel_id: &str,
247) -> Result<String, String> {
248    let url = format!("{CF_API_BASE}/zones/{zone_id}/dns_records");
249    let payload = json!({
250        "type": "CNAME",
251        "name": record_name,
252        "content": format!("{tunnel_id}.cfargotunnel.com"),
253        "proxied": true,
254        "ttl": 1
255    });
256
257    let resp = authed(client()?.post(&url), token)
258        .json(&payload)
259        .send()
260        .map_err(|e| format!("cloudflare tunnel dns route request failed: {e}"))?;
261
262    // 81053 means record already exists. If so, we could update it, but for now we ignore or return OK.
263    let status = resp.status();
264    let body = resp.text().unwrap_or_default();
265    if !status.is_success() {
266        if body.contains("81053") {
267            return Ok("already_exists".to_string());
268        }
269        return Err(format!("cloudflare tunnel dns route HTTP {status}: {body}"));
270    }
271
272    let json: Value = serde_json::from_str(&body).unwrap_or_default();
273    json.get("result")
274        .and_then(|r| r.get("id"))
275        .and_then(Value::as_str)
276        .map(|s| s.to_string())
277        .ok_or_else(|| format!("cloudflare tunnel dns route missing id: {body}"))
278}
279
280/// Provision a Cloudflare Pages Project linked to a GitHub repository (`POST /accounts/{account_id}/pages/projects`).
281#[cfg(not(target_arch = "wasm32"))]
282pub fn provision_pages_project(
283    token: &str,
284    account_id: &str,
285    project_name: &str,
286    github_repo: &str,
287) -> Result<String, String> {
288    let url = format!("{}/accounts/{}/pages/projects", CF_API_BASE, account_id);
289    let payload = json!({
290        "name": project_name,
291        "source": {
292            "type": "github",
293            "config": {
294                "owner": github_repo.split('/').next().unwrap_or(""),
295                "repo_name": github_repo.split('/').nth(1).unwrap_or(""),
296                "production_branch": "main",
297                "pr_comments_enabled": false,
298                "deployments_enabled": true
299            }
300        },
301        "build_config": {
302            "build_command": "",
303            "destination_dir": "",
304            "root_dir": "",
305            "web_analytics_tag": null,
306            "web_analytics_token": null
307        }
308    });
309
310    let resp = authed(client()?.post(&url), token)
311        .json(&payload)
312        .send()
313        .map_err(|e| format!("cloudflare pages provision request failed: {}", e))?;
314
315    let status = resp.status();
316    let body = resp.text().unwrap_or_default();
317
318    // 8000007 means project already exists, which is fine
319    if !status.is_success() && !body.contains("8000007") {
320        return Err(format!(
321            "cloudflare pages provision HTTP {}: {}",
322            status, body
323        ));
324    }
325
326    Ok("success".to_string())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::domains::AgentType;
333
334    fn minimal_record() -> FrontDoorRecord {
335        FrontDoorRecord {
336            domain: "a.example".into(),
337            agent_type: AgentType::NaturalPerson,
338            front_door_did: "did:qdp:a".into(),
339            name: None,
340            webid: None,
341            services: vec![],
342            identity_pubkey_hex: None,
343            wireguard_pubkey_hex: None,
344            overlay_addr: None,
345            profile_url: None,
346        }
347    }
348
349    #[test]
350    fn dns_record_payload_is_a_qdp_txt_record() {
351        let p = dns_record_payload(&minimal_record());
352        assert_eq!(p["type"], "TXT");
353        let name = p["name"].as_str().unwrap();
354        assert!(
355            name.starts_with("_qdp."),
356            "name should be _qdp.<domain>, got {name}"
357        );
358        let content = p["content"].as_str().unwrap();
359        assert!(!content.is_empty(), "TXT content must be non-empty");
360        assert_eq!(p["ttl"], 300);
361    }
362
363    #[test]
364    fn parse_zone_list_collects_id_name_pairs() {
365        let json = json!({ "result": [ { "id": "z1", "name": "a.example" } ] });
366        assert_eq!(
367            parse_zone_list(&json),
368            vec![("z1".to_string(), "a.example".to_string())]
369        );
370    }
371
372    #[test]
373    fn parse_zone_list_empty_when_no_result() {
374        assert!(parse_zone_list(&json!({})).is_empty());
375        assert!(parse_zone_list(&json!({ "result": [] })).is_empty());
376    }
377
378    #[test]
379    fn parse_verify_token_ok_only_when_active() {
380        assert!(
381            parse_verify_token(&json!({ "success": true, "result": { "status": "active" } }))
382                .is_ok()
383        );
384        // successful call but the token is disabled/expired
385        assert!(parse_verify_token(
386            &json!({ "success": true, "result": { "status": "disabled" } })
387        )
388        .is_err());
389        // call itself failed
390        assert!(
391            parse_verify_token(&json!({ "success": false, "result": { "status": "active" } }))
392                .is_err()
393        );
394        // malformed
395        assert!(parse_verify_token(&json!({})).is_err());
396    }
397}