main
John Lauer 0.2.21 source: script-error auto-recovery + errorShot, symbol_prims/set_models fix for AD 26.9.1, ENGINE_HALTING releas… a577bb1 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
//! Altium 365: OAuth/PKCE (the public-client ActionWait flow from Altium's
//! `AltiumDeveloper/a365-auth` spec) and API primitives (GraphQL, Files Service upload).
//! Deliberately no Altium Designer dependency: the cloud lane works while X2.exe is closed.
//! Tokens live in the bridge cache (a365.json) and NEVER travel in verb responses.
use crate::agent;
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::Duration;

const AUTH_BASE: &str = "https://auth.altium.com";
const ACTIONWAIT_ENDPOINT: &str = "https://actionwait.altium.com/await";

fn token_endpoint() -> String { format!("{AUTH_BASE}/connect/token") }
fn revocation_endpoint() -> String { format!("{AUTH_BASE}/connect/revocation") }
fn redirect_uri() -> String { format!("{AUTH_BASE}/api/AuthComplete") }

// ---- config store ------------------------------------------------------------

fn cfg_path() -> std::path::PathBuf {
    agent::cache_dir().join("a365.json")
}

fn load() -> Value {
    std::fs::read_to_string(cfg_path()).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| json!({}))
}

/// Atomic local-only persistence; tokens never travel in verb responses.
fn save(cfg: &Value) {
    let p = cfg_path();
    let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
    let tmp = p.with_extension("tmp");
    if std::fs::write(&tmp, cfg.to_string()).is_ok() {
        let _ = std::fs::rename(&tmp, &p);
    }
}

fn access_token(cfg: &Value) -> Option<String> {
    cfg.get("token").or_else(|| cfg.get("accessToken")).and_then(|t| t.as_str()).map(str::to_string)
}

// ---- crypto / encoding helpers -----------------------------------------------

fn b64url(raw: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);
    for chunk in raw.chunks(3) {
        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
        out.push(T[(n >> 18) as usize & 63] as char);
        out.push(T[(n >> 12) as usize & 63] as char);
        if chunk.len() > 1 { out.push(T[(n >> 6) as usize & 63] as char); }
        if chunk.len() > 2 { out.push(T[n as usize & 63] as char); }
    }
    out
}

fn b64url_decode(s: &str) -> Option<Vec<u8>> {
    let val = |c: u8| -> Option<u32> {
        Some(match c {
            b'A'..=b'Z' => (c - b'A') as u32,
            b'a'..=b'z' => (c - b'a' + 26) as u32,
            b'0'..=b'9' => (c - b'0' + 52) as u32,
            b'-' | b'+' => 62,
            b'_' | b'/' => 63,
            _ => return None,
        })
    };
    let bytes: Vec<u8> = s.bytes().filter(|b| *b != b'=').collect();
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
    for chunk in bytes.chunks(4) {
        let mut n: u32 = 0;
        for (i, b) in chunk.iter().enumerate() {
            n |= val(*b)? << (18 - 6 * i);
        }
        out.push((n >> 16) as u8);
        if chunk.len() > 2 { out.push((n >> 8) as u8); }
        if chunk.len() > 3 { out.push(n as u8); }
    }
    Some(out)
}

fn sha256_raw(data: &[u8]) -> Vec<u8> {
    let hex = crate::lifecycle::sha256_hex(data);
    (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0)).collect()
}

fn random_bytes(n: usize) -> Result<Vec<u8>, String> {
    let mut buf = vec![0u8; n];
    getrandom::getrandom(&mut buf).map_err(|e| format!("no OS entropy: {e}"))?;
    Ok(buf)
}

fn uuid4() -> Result<String, String> {
    let b = random_bytes(16)?;
    Ok(format!(
        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:x}{:02x}-{:x}{:x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
        b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0x0f, b[7], 8 + (b[8] & 3), b[8] & 0x0f, b[9],
        b[10], b[11], b[12], b[13], b[14], b[15]
    ))
}

fn urlencode(s: &str) -> String {
    s.bytes().map(|b| match b {
        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
        b' ' => "+".into(),
        _ => format!("%{b:02X}"),
    }).collect()
}

/// Non-sensitive JWT metadata for display only; never for authorization.
fn unverified_claim_summary(access_token: &str) -> Value {
    let part = match access_token.split('.').nth(1) {
        Some(p) => p,
        None => return json!({}),
    };
    let claims: Value = match b64url_decode(part).and_then(|b| serde_json::from_slice(&b).ok()) {
        Some(c) => c,
        None => return json!({}),
    };
    let mut out = serde_json::Map::new();
    for k in ["iss", "client_id", "workspaceId", "scope", "exp"] {
        if let Some(v) = claims.get(k) {
            out.insert(k.into(), v.clone());
        }
    }
    Value::Object(out)
}

// ---- HTTP helpers ------------------------------------------------------------

/// (status, parsed JSON body). Network failure -> Err(message).
fn request_json(req: ureq::Request, body: RequestBody) -> Result<(u16, Value), String> {
    let resp = match body {
        RequestBody::Form(fields) => {
            let pairs: Vec<(&str, &str)> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect();
            req.send_form(&pairs)
        }
        RequestBody::Json(v) => req.send_json(v),
        RequestBody::Bytes(b) => req.send_bytes(&b),
    };
    let (status, text) = match resp {
        Ok(r) => (r.status(), r.into_string().unwrap_or_default()),
        Err(ureq::Error::Status(code, r)) => (code, r.into_string().unwrap_or_default()),
        Err(e) => return Err(format!("Altium request failed: {e}")),
    };
    let data = if text.is_empty() { json!({}) } else {
        serde_json::from_str(&text).unwrap_or_else(|_| json!({"_raw": text}))
    };
    Ok((status, data))
}

enum RequestBody {
    Form(Vec<(&'static str, String)>),
    Json(Value),
    Bytes(Vec<u8>),
}

// ---- OAuth state -------------------------------------------------------------

static OAUTH: Mutex<Option<Value>> = Mutex::new(None);
static OAUTH_CANCELLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

fn oauth_state() -> Value {
    OAUTH.lock().ok().and_then(|g| g.clone()).unwrap_or_else(|| json!({"status": "idle"}))
}

fn set_oauth(v: Value) {
    if let Ok(mut g) = OAUTH.lock() {
        *g = Some(v);
    }
}

// ---- verbs -------------------------------------------------------------------

/// Store the workspace domain + a Bearer token (created in the workspace web UI under
/// Admin → Developer on plans that expose it).
pub fn configure(args: &Value) -> Value {
    let mut cfg = load();
    for k in ["workspace", "token"] {
        if let Some(v) = args.get(k).and_then(|x| x.as_str()) {
            cfg[k] = json!(v);
        }
    }
    if cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").is_empty() {
        return json!({"success": false, "error": "workspace is required, e.g. 'acme.365.altium.com'"});
    }
    save(&cfg);
    json!({"success": true, "workspace": cfg.get("workspace"), "hasToken": cfg.get("token").map(|t| !t.is_null()).unwrap_or(false),
        "_hint": "altium_a365_status to verify; altium_a365_query {query} to run GraphQL."})
}

pub fn status(_args: &Value) -> Value {
    let cfg = load();
    let ws = cfg.get("workspace").and_then(|w| w.as_str()).map(str::to_string);
    let mut out = json!({"success": true, "configured": ws.is_some(), "workspace": ws,
        "hasToken": access_token(&cfg).is_some(),
        "hasRefreshToken": cfg.get("refreshToken").map(|t| !t.is_null()).unwrap_or(false),
        "clientConfigured": cfg.get("clientId").map(|t| !t.is_null()).unwrap_or(false),
        "endpoint": cfg.get("workspace").and_then(|w| w.as_str()).map(|w| format!("https://{w}/svc/napi/gateway/graphql/"))});
    if access_token(&cfg).is_none() {
        out["_hint"] = json!("No access token. Preferred path: register an Altium public OAuth client, then altium_a365_oauth_begin {clientId, workspace}; open authorizeUrl only after the verb returns. Manual Admin → Developer tokens remain supported by altium_a365_configure.");
        return out;
    }
    let r = query(&json!({"query": "{ desProjects(first: 1) { totalCount } }"}));
    out["tokenLive"] = json!(r.get("success").and_then(|b| b.as_bool()).unwrap_or(false));
    out["probe"] = r.get("data").or_else(|| r.get("error")).cloned().unwrap_or(Value::Null);
    out
}

pub fn query(args: &Value) -> Value {
    let cfg = load();
    let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();
    let token = access_token(&cfg);
    if ws.is_empty() || token.is_none() {
        return json!({"success": false, "errorCode": "a365_not_configured",
            "_hint": "altium_a365_oauth_begin {clientId, workspace} or altium_a365_configure {workspace, token}"});
    }
    let q = match args.get("query").and_then(|x| x.as_str()) {
        Some(q) if !q.is_empty() => q.to_string(),
        _ => return json!({"success": false, "error": "query (GraphQL) is required",
            "_hint": "e.g. { desProjects(first:10){ nodes { name } } }"}),
    };
    let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');
    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);
    let req = ureq::post(&format!("https://{host}/svc/napi/gateway/graphql/"))
        .timeout(Duration::from_secs(timeout))
        .set("Authorization", &format!("Bearer {}", token.unwrap()));
    let body = json!({"query": q, "variables": args.get("variables").cloned().unwrap_or(json!({}))});
    match request_json(req, RequestBody::Json(body)) {
        Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e,
            "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),
        Ok((s, _)) if s != 200 => json!({"success": false, "errorCode": "a365_graphql_http",
            "error": format!("GraphQL returned HTTP {s}."),
            "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),
        Ok((_, data)) => json!({"success": data.get("errors").is_none(),
            "data": data.get("data"), "errors": data.get("errors"),
            "_hint": "Explore the schema at https://<workspace>/api/voyager."}),
    }
}

/// Start ActionWait BEFORE returning the browser URL (Altium SPEC section 4.4).
pub fn oauth_begin(args: &Value) -> Value {
    let cfg = load();
    let client_id = args.get("clientId").or_else(|| cfg.get("clientId")).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
    let workspace = args.get("workspace").or_else(|| cfg.get("workspace")).and_then(|x| x.as_str()).unwrap_or("").to_string();
    if workspace.is_empty() {
        return json!({"success": false, "errorCode": "a365_workspace_required",
            "error": "workspace is required, e.g. acme.365.altium.com"});
    }
    if client_id.is_empty() {
        return json!({"success": false, "errorCode": "a365_client_id_required",
            "error": "An Altium-issued OAuth client ID is required.",
            "_hint": "Altium OAuth clients are issued through Developer Center enrollment; do not reuse a first-party client ID."});
    }
    if oauth_state().get("status").and_then(|s| s.as_str()) == Some("waiting") {
        return json!({"success": false, "errorCode": "a365_oauth_in_progress",
            "_hint": "altium_a365_oauth_status; cancel the old flow before starting another."});
    }
    let select_workspace = args.get("selectWorkspace").and_then(|s| s.as_str()).unwrap_or("strict").to_string();
    if !matches!(select_workspace.as_str(), "none" | "strict" | "optional") {
        return json!({"success": false, "errorCode": "a365_bad_workspace_selection",
            "error": "selectWorkspace must be none, strict, or optional."});
    }
    let verifier = match random_bytes(32) {
        Ok(b) => b64url(&b),
        Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),
    };
    let challenge = b64url(&sha256_raw(verifier.as_bytes()));
    let wait_token = match uuid4() {
        Ok(u) => u,
        Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),
    };
    let mut params = vec![
        ("client_id", client_id.clone()),
        ("response_type", "code".to_string()),
        ("scope", "openid profile offline_access".to_string()),
        ("redirect_uri", redirect_uri()),
        ("code_challenge", challenge),
        ("code_challenge_method", "S256".to_string()),
        ("state", wait_token.clone()),
    ];
    if select_workspace != "none" {
        params.push(("selectWorkspace", select_workspace));
    }
    let qs = params.iter().map(|(k, v)| format!("{k}={}", urlencode(v))).collect::<Vec<_>>().join("&");
    let authorize_url = format!("{AUTH_BASE}/connect/authorize?{qs}");
    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(300);
    OAUTH_CANCELLED.store(false, std::sync::atomic::Ordering::SeqCst);
    set_oauth(json!({"status": "waiting", "workspace": workspace,
        "startedAt": crate::support::now() as i64, "expiresInSec": timeout}));
    let (cid, ws, wt, ver) = (client_id, workspace, wait_token, verifier);
    let _ = std::thread::Builder::new().name("altium-a365-oauth".into()).spawn(move || {
        oauth_worker(&cid, &ws, &wt, &ver, timeout);
    });
    json!({"success": true, "status": "waiting", "workspace": oauth_state().get("workspace"),
        "authorizeUrl": authorize_url,
        "_hint": "ActionWait is listening now. Open authorizeUrl in the user's existing browser, then poll altium_a365_oauth_status. Tokens never appear in verb output."})
}

fn oauth_worker(client_id: &str, workspace: &str, wait_token: &str, verifier: &str, overall_timeout: u64) {
    // Long-poll ActionWait for the one-time code. HTTP 408 = reconnect; 410 = terminal.
    let deadline = std::time::Instant::now() + Duration::from_secs(overall_timeout);
    let code = loop {
        if OAUTH_CANCELLED.load(std::sync::atomic::Ordering::SeqCst) {
            set_oauth(json!({"status": "cancelled"}));
            return;
        }
        if std::time::Instant::now() >= deadline {
            set_oauth(json!({"status": "error", "errorCode": "a365_oauth_timeout", "error": "Timed out waiting for Altium sign-in."}));
            return;
        }
        let req = ureq::post(ACTIONWAIT_ENDPOINT).timeout(Duration::from_secs(75));
        match request_json(req, RequestBody::Json(json!({"token": wait_token}))) {
            Err(e) => {
                set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));
                return;
            }
            Ok((408, _)) => continue,
            Ok((410, _)) => {
                set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_gone", "error": "ActionWait token was consumed or superseded."}));
                return;
            }
            Ok((200, data)) => {
                let result = data.get("data").cloned().unwrap_or(Value::Null);
                let code = result.get("code").and_then(|c| c.as_str()).unwrap_or("").to_string();
                let state = result.get("state").and_then(|s| s.as_str()).unwrap_or("").to_string();
                if code.is_empty() || state.is_empty() {
                    set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_shape", "error": "ActionWait response omitted code or state."}));
                    return;
                }
                if state != wait_token {
                    set_oauth(json!({"status": "error", "errorCode": "a365_state_mismatch", "error": "OAuth state mismatch; authorization was rejected."}));
                    return;
                }
                break code;
            }
            Ok((s, _)) => {
                set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_http", "error": format!("ActionWait returned HTTP {s}.")}));
                return;
            }
        }
    };
    // exchange the code
    let req = ureq::post(&token_endpoint()).timeout(Duration::from_secs(60));
    let fields = vec![
        ("grant_type", "authorization_code".to_string()),
        ("code", code),
        ("code_verifier", verifier.to_string()),
        ("redirect_uri", redirect_uri()),
        ("client_id", client_id.to_string()),
    ];
    let tokens = match request_json(req, RequestBody::Form(fields)) {
        Ok((200, data)) if data.get("access_token").is_some() => data,
        Ok((s, data)) => {
            let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
            set_oauth(json!({"status": "error", "errorCode": "a365_token_exchange_failed",
                "error": format!("Token exchange failed ({}).", if oe.is_empty() { s.to_string() } else { oe })}));
            return;
        }
        Err(e) => {
            set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));
            return;
        }
    };
    let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();
    let mut cfg = load();
    cfg["workspace"] = json!(workspace);
    cfg["clientId"] = json!(client_id);
    cfg["token"] = json!(at);
    cfg["accessToken"] = json!(at);
    if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {
        cfg["refreshToken"] = json!(rt);
    }
    cfg["tokenEndpoint"] = json!(token_endpoint());
    cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));
    save(&cfg);
    set_oauth(json!({"status": "authorized", "workspace": workspace,
        "claims": unverified_claim_summary(&at)}));
}

pub fn oauth_status(_args: &Value) -> Value {
    let mut state = oauth_state();
    let status = state.get("status").and_then(|s| s.as_str()).unwrap_or("idle").to_string();
    state["success"] = json!(status != "error");
    if status == "waiting" {
        state["_hint"] = json!("Complete the Altium consent page in the browser; ActionWait is already listening.");
    } else if status == "authorized" {
        state["_hint"] = json!("OAuth is live. altium_a365_library_overview proves the workspace-scoped token.");
    }
    state
}

pub fn oauth_cancel(_args: &Value) -> Value {
    if oauth_state().get("status").and_then(|s| s.as_str()) != Some("waiting") {
        return json!({"success": true, "alreadyStopped": true, "status": oauth_state().get("status").cloned().unwrap_or(json!("idle"))});
    }
    OAUTH_CANCELLED.store(true, std::sync::atomic::Ordering::SeqCst);
    json!({"success": true, "status": "cancelling", "_hint": "Poll altium_a365_oauth_status."})
}

pub fn refresh(_args: &Value) -> Value {
    let mut cfg = load();
    let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();
    let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();
    if client_id.is_empty() || refresh_token.is_empty() {
        return json!({"success": false, "errorCode": "a365_refresh_not_configured",
            "_hint": "Complete altium_a365_oauth_begin first with offline_access."});
    }
    let endpoint = cfg.get("tokenEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(token_endpoint);
    let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));
    let fields = vec![
        ("grant_type", "refresh_token".to_string()),
        ("refresh_token", refresh_token),
        ("client_id", client_id),
    ];
    let tokens = match request_json(req, RequestBody::Form(fields)) {
        Ok((200, data)) if data.get("access_token").is_some() => data,
        Ok((s, data)) => {
            let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
            return json!({"success": false, "errorCode": "a365_refresh_failed",
                "error": format!("Token refresh failed ({}).", if oe.is_empty() { s.to_string() } else { oe })});
        }
        Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e}),
    };
    let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();
    cfg["token"] = json!(at);
    cfg["accessToken"] = json!(at);
    if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {
        cfg["refreshToken"] = json!(rt);
    }
    cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));
    save(&cfg);
    json!({"success": true, "workspace": cfg.get("workspace"),
        "claims": unverified_claim_summary(&at),
        "_hint": "Access token refreshed; rotated refresh token (if any) was stored atomically."})
}

/// Revoke the refresh grant and remove all locally cached OAuth tokens.
pub fn logout(_args: &Value) -> Value {
    let mut cfg = load();
    let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();
    let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();
    let mut revoked = false;
    if !refresh_token.is_empty() && !client_id.is_empty() {
        let endpoint = cfg.get("revocationEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(revocation_endpoint);
        let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));
        let fields = vec![
            ("token", refresh_token),
            ("token_type_hint", "refresh_token".to_string()),
            ("client_id", client_id),
        ];
        match request_json(req, RequestBody::Form(fields)) {
            Ok((200, _)) => revoked = true,
            Ok((s, _)) => return json!({"success": false, "errorCode": "a365_revoke_failed",
                "error": format!("Token revocation returned HTTP {s}."),
                "_hint": "The local grant was retained because remote revocation did not succeed."}),
            Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e,
                "_hint": "The local grant was retained because remote revocation did not succeed."}),
        }
    }
    if let Some(o) = cfg.as_object_mut() {
        for k in ["token", "accessToken", "refreshToken", "expiresAt"] {
            o.remove(k);
        }
    }
    save(&cfg);
    json!({"success": true, "revoked": revoked, "workspace": cfg.get("workspace"),
        "_hint": "OAuth tokens were removed from the bridge cache."})
}

/// Upload an opaque mutation input through the documented Files Service.
pub fn files_upload(args: &Value) -> Value {
    let cfg = load();
    let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();
    let token = access_token(&cfg);
    if ws.is_empty() || token.is_none() {
        return json!({"success": false, "errorCode": "a365_not_configured",
            "_hint": "Complete altium_a365_oauth_begin first."});
    }
    let file_path = match args.get("filePath").and_then(|f| f.as_str()) {
        Some(f) if !f.is_empty() => f.to_string(),
        _ => return json!({"success": false, "errorCode": "a365_file_required", "error": "filePath is required"}),
    };
    let max_bytes = args.get("maxBytes").and_then(|m| m.as_u64()).unwrap_or(100 * 1024 * 1024);
    let meta = match std::fs::metadata(&file_path) {
        Ok(m) if m.is_file() => m,
        _ => return json!({"success": false, "errorCode": "a365_file_not_found",
            "error": format!("Upload file does not exist: {file_path}")}),
    };
    if meta.len() > max_bytes {
        return json!({"success": false, "errorCode": "a365_file_too_large",
            "error": format!("Upload is {} bytes; limit is {max_bytes} bytes.", meta.len())});
    }
    let content = match std::fs::read(&file_path) {
        Ok(c) => c,
        Err(e) => return json!({"success": false, "errorCode": "a365_file_not_found", "error": format!("{e}")}),
    };
    let fname = std::path::Path::new(&file_path).file_name()
        .map(|n| n.to_string_lossy().replace('"', "_"))
        .unwrap_or_else(|| "upload".into());
    let boundary = format!("----adom-a365-{}", b64url(&random_bytes(16).unwrap_or_default()).to_lowercase());
    let mut body = format!(
        "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
    ).into_bytes();
    body.extend_from_slice(&content);
    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
    let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');
    let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(120);
    let req = ureq::post(&format!("https://{host}/svc/napi/files/File/Upload"))
        .timeout(Duration::from_secs(timeout))
        .set("Content-Type", &format!("multipart/form-data; boundary={boundary}"))
        .set("Authorization", &format!("Bearer {}", token.unwrap()));
    match request_json(req, RequestBody::Bytes(body)) {
        Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e}),
        Ok((200, data)) => {
            let file_id = data.get("_raw").and_then(|r| r.as_str())
                .map(|s| s.trim().trim_matches('"').to_string())
                .or_else(|| data.as_str().map(str::to_string))
                .unwrap_or_else(|| data.to_string().trim_matches('"').to_string());
            if file_id.is_empty() || file_id == "{}" {
                return json!({"success": false, "errorCode": "a365_file_upload_failed", "error": "Files Service returned an empty id."});
            }
            json!({"success": true, "fileId": file_id, "expiresAfterHoursInactive": 24,
                "_hint": "Temporary opaque file ID. Pass it to the matching library mutation; do not parse or persist it."})
        }
        Ok((s, _)) => json!({"success": false, "errorCode": "a365_file_upload_failed",
            "error": format!("Files Service returned HTTP {s}.")}),
    }
}

pub fn library_overview(_args: &Value) -> Value {
    query(&json!({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { nodes { id name } } footprints { totalCount } symbols { totalCount } } }"}))
}

pub fn library_schema(args: &Value) -> Value {
    let q = "query LibrarySchema {\n  __schema {\n    queryType { fields { name description } }\n    mutationType { fields { name description args { name type { kind name ofType { kind name } } } } }\n  }\n}";
    let result = query(&json!({"query": q, "timeout": args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60)}));
    if result.get("success").and_then(|b| b.as_bool()) != Some(true) {
        return result;
    }
    let schema = result.get("data").and_then(|d| d.get("__schema")).cloned().unwrap_or(json!({}));
    let pattern = ["lib", "component", "symbol", "footprint", "part", "datasheet"];
    let relevant = |fields: Option<&Value>| -> Vec<Value> {
        fields.and_then(|f| f.as_array()).map(|a| {
            a.iter().filter(|f| {
                let name = f.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();
                pattern.iter().any(|p| name.contains(p))
            }).cloned().collect()
        }).unwrap_or_default()
    };
    json!({"success": true,
        "queries": relevant(schema.get("queryType").and_then(|t| t.get("fields"))),
        "mutations": relevant(schema.get("mutationType").and_then(|t| t.get("fields"))),
        "_hint": "Read-only schema map. Choose a mutation only after its input types and rollback/delete mutation are understood."})
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn b64url_roundtrip_and_jwt_claims() {
        assert_eq!(b64url(b"foobar"), "Zm9vYmFy");
        assert_eq!(b64url_decode("Zm9vYmFy").unwrap(), b"foobar");
        // urlsafe chars
        let raw: Vec<u8> = vec![0xfb, 0xff, 0xbe];
        let enc = b64url(&raw);
        assert!(!enc.contains('+') && !enc.contains('/'));
        assert_eq!(b64url_decode(&enc).unwrap(), raw);
        // a fake JWT: header.payload.sig with payload {"iss":"x","exp":1}
        let payload = b64url(br#"{"iss":"x","exp":1,"secret":"never"}"#);
        let claims = unverified_claim_summary(&format!("h.{payload}.s"));
        assert_eq!(claims["iss"], json!("x"));
        assert!(claims.get("secret").is_none(), "only allow-listed claims may surface");
    }

    #[test]
    fn pkce_challenge_matches_reference() {
        // RFC 7636 appendix B vector
        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
        let challenge = b64url(&sha256_raw(verifier.as_bytes()));
        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
    }

    #[test]
    fn urlencode_is_form_safe() {
        assert_eq!(urlencode("a b+c/d"), "a+b%2Bc%2Fd");
        assert_eq!(urlencode("openid profile offline_access"), "openid+profile+offline_access");
    }
}