app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
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");
}
}