Skip to main content

rns_ctl/
api.rs

1use serde_json::{json, Value};
2
3use rns_crypto::identity::Identity;
4use rns_net::{
5    event::LifecycleState, DestHash, Destination, IdentityHash, ProofStrategy, QueryRequest,
6    QueryResponse, RnsNode,
7};
8
9use crate::auth::check_auth;
10use crate::encode::{from_base64, hex_to_array, to_base64, to_hex};
11use crate::http::{parse_query, HttpRequest, HttpResponse};
12use crate::state::{ControlPlaneConfigHandle, DestinationEntry, SharedState};
13
14/// Handle for the node, wrapped so shutdown() can consume it.
15pub type NodeHandle = std::sync::Arc<std::sync::Mutex<Option<RnsNode>>>;
16
17/// Execute a closure with a reference to the node, returning an error response if the node is gone.
18fn with_node<F>(node: &NodeHandle, f: F) -> HttpResponse
19where
20    F: FnOnce(&RnsNode) -> HttpResponse,
21{
22    let guard = node.lock().unwrap();
23    match guard.as_ref() {
24        Some(n) => f(n),
25        None => HttpResponse::internal_error("Node is shutting down"),
26    }
27}
28
29fn with_active_node<F>(node: &NodeHandle, f: F) -> HttpResponse
30where
31    F: FnOnce(&RnsNode) -> HttpResponse,
32{
33    with_node(node, |n| {
34        match n.query(QueryRequest::DrainStatus) {
35            Ok(QueryResponse::DrainStatus(status))
36                if !matches!(status.state, LifecycleState::Active) =>
37            {
38                HttpResponse::conflict(
39                    status
40                        .detail
41                        .as_deref()
42                        .unwrap_or("Node is draining and not accepting new work"),
43                )
44            }
45            _ => f(n),
46        }
47    })
48}
49
50/// Route dispatch: match method + path and call the appropriate handler.
51pub fn handle_request(
52    req: &HttpRequest,
53    node: &NodeHandle,
54    state: &SharedState,
55    config: &ControlPlaneConfigHandle,
56) -> HttpResponse {
57    if req.method == "GET" && (req.path == "/" || req.path == "/ui") {
58        return HttpResponse::html(index_html(config));
59    }
60    if req.method == "GET" && req.path == "/assets/app.css" {
61        return HttpResponse::bytes(
62            200,
63            "OK",
64            "text/css; charset=utf-8",
65            include_str!("../assets/app.css").as_bytes().to_vec(),
66        );
67    }
68    if req.method == "GET" && req.path == "/assets/app.js" {
69        return HttpResponse::bytes(
70            200,
71            "OK",
72            "application/javascript; charset=utf-8",
73            include_str!("../assets/app.js").as_bytes().to_vec(),
74        );
75    }
76
77    // Health check — no auth required
78    if req.method == "GET" && req.path == "/health" {
79        return HttpResponse::ok(json!({"status": "healthy"}));
80    }
81
82    // All other endpoints require auth
83    if let Err(resp) = check_auth(req, config) {
84        return resp;
85    }
86
87    match (req.method.as_str(), req.path.as_str()) {
88        // Read endpoints
89        ("GET", "/api/node") => handle_node(node, state),
90        ("GET", "/api/config") => handle_config(state),
91        ("GET", "/api/config/schema") => handle_config_schema(state),
92        ("GET", "/api/config/status") => handle_config_status(state),
93        ("GET", "/api/processes") => handle_processes(state),
94        ("GET", "/api/process_events") => handle_process_events(state),
95        ("GET", path) if path.starts_with("/api/processes/") && path.ends_with("/logs") => {
96            handle_process_logs(path, req, state)
97        }
98        ("GET", "/api/info") => handle_info(node, state),
99        ("GET", "/api/interfaces") => handle_interfaces(node),
100        ("GET", "/api/destinations") => handle_destinations(node, state),
101        ("GET", "/api/paths") => handle_paths(req, node),
102        ("GET", "/api/links") => handle_links(node),
103        ("GET", "/api/resources") => handle_resources(node),
104        ("GET", "/api/announces") => handle_event_list(req, state, "announces"),
105        ("GET", "/api/packets") => handle_event_list(req, state, "packets"),
106        ("GET", "/api/proofs") => handle_event_list(req, state, "proofs"),
107        ("GET", "/api/link_events") => handle_event_list(req, state, "link_events"),
108        ("GET", "/api/resource_events") => handle_event_list(req, state, "resource_events"),
109
110        // Identity recall: /api/identity/<dest_hash>
111        ("GET", path) if path.starts_with("/api/identity/") => {
112            let hash_str = &path["/api/identity/".len()..];
113            handle_recall_identity(hash_str, node)
114        }
115
116        // Action endpoints
117        ("POST", "/api/destination") => handle_post_destination(req, node, state),
118        ("POST", "/api/announce") => handle_post_announce(req, node, state),
119        ("POST", "/api/send") => handle_post_send(req, node, state),
120        ("POST", "/api/config/validate") => handle_config_validate(req, state),
121        ("POST", "/api/config") => {
122            handle_config_mutation(req, state, crate::state::ServerConfigMutationMode::Save)
123        }
124        ("POST", "/api/config/apply") => {
125            handle_config_mutation(req, state, crate::state::ServerConfigMutationMode::Apply)
126        }
127        ("POST", "/api/link") => handle_post_link(req, node),
128        ("POST", "/api/link/send") => handle_post_link_send(req, node),
129        ("POST", "/api/link/close") => handle_post_link_close(req, node),
130        ("POST", "/api/channel") => handle_post_channel(req, node),
131        ("POST", "/api/resource") => handle_post_resource(req, node),
132        ("POST", "/api/path/request") => handle_post_path_request(req, node),
133        ("POST", "/api/direct_connect") => handle_post_direct_connect(req, node),
134        ("POST", "/api/announce_queues/clear") => handle_post_clear_announce_queues(node),
135        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/restart") => {
136            handle_process_control(path, state, "restart")
137        }
138        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/start") => {
139            handle_process_control(path, state, "start")
140        }
141        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/stop") => {
142            handle_process_control(path, state, "stop")
143        }
144
145        // Backbone peer state
146        ("GET", "/api/backbone/peers") => handle_backbone_peers(req, node),
147        ("POST", "/api/backbone/blacklist") => handle_backbone_blacklist(req, node),
148
149        // Hook management
150        ("GET", "/api/hooks") => handle_list_hooks(node),
151        ("POST", "/api/hook/load") => handle_load_hook(req, node),
152        ("POST", "/api/hook/unload") => handle_unload_hook(req, node),
153        ("POST", "/api/hook/reload") => handle_reload_hook(req, node),
154        ("POST", "/api/hook/enable") => handle_set_hook_enabled(req, node, true),
155        ("POST", "/api/hook/disable") => handle_set_hook_enabled(req, node, false),
156        ("POST", "/api/hook/priority") => handle_set_hook_priority(req, node),
157
158        _ => HttpResponse::not_found(),
159    }
160}
161
162fn index_html(_config: &ControlPlaneConfigHandle) -> &'static str {
163    include_str!("../assets/index_auth.html")
164}
165
166// --- Read handlers ---
167
168fn handle_node(node: &NodeHandle, state: &SharedState) -> HttpResponse {
169    let (transport_id, drain_status) = {
170        let guard = node.lock().unwrap();
171        let Some(node) = guard.as_ref() else {
172            return HttpResponse::internal_error("Node is shutting down");
173        };
174        let transport_id = match node.query(QueryRequest::TransportIdentity) {
175            Ok(QueryResponse::TransportIdentity(id)) => id,
176            _ => None,
177        };
178        let drain_status = match node.query(QueryRequest::DrainStatus) {
179            Ok(QueryResponse::DrainStatus(status)) => Some(status),
180            _ => None,
181        };
182        (transport_id, drain_status)
183    };
184
185    let s = state.read().unwrap();
186    HttpResponse::ok(json!({
187        "server_mode": s.server_mode,
188        "uptime_seconds": s.uptime_seconds(),
189        "transport_id": transport_id.map(|h| to_hex(&h)),
190        "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
191        "process_count": s.processes.len(),
192        "processes_running": s.processes.values().filter(|p| p.status == "running").count(),
193        "processes_ready": s.processes.values().filter(|p| p.ready).count(),
194        "drain": drain_status.map(|status| json!({
195            "state": format!("{:?}", status.state).to_lowercase(),
196            "drain_age_seconds": status.drain_age_seconds,
197            "deadline_remaining_seconds": status.deadline_remaining_seconds,
198            "drain_complete": status.drain_complete,
199            "interface_writer_queued_frames": status.interface_writer_queued_frames,
200            "provider_backlog_events": status.provider_backlog_events,
201            "provider_consumer_queued_events": status.provider_consumer_queued_events,
202            "detail": status.detail,
203        })),
204    }))
205}
206
207fn handle_config(state: &SharedState) -> HttpResponse {
208    let s = state.read().unwrap();
209    match &s.server_config {
210        Some(config) => HttpResponse::ok(json!({ "config": config })),
211        None => HttpResponse::ok(json!({ "config": null })),
212    }
213}
214
215fn handle_config_schema(state: &SharedState) -> HttpResponse {
216    let s = state.read().unwrap();
217    match &s.server_config_schema {
218        Some(schema) => HttpResponse::ok(json!({ "schema": schema })),
219        None => HttpResponse::ok(json!({ "schema": null })),
220    }
221}
222
223fn handle_config_status(state: &SharedState) -> HttpResponse {
224    let s = state.read().unwrap();
225    HttpResponse::ok(json!({
226        "status": s.server_config_status.snapshot(),
227    }))
228}
229
230fn handle_config_validate(req: &HttpRequest, state: &SharedState) -> HttpResponse {
231    let validator = {
232        let s = state.read().unwrap();
233        s.server_config_validator.clone()
234    };
235
236    match validator {
237        Some(validator) => match validator(&req.body) {
238            Ok(result) => HttpResponse::ok(json!({ "result": result })),
239            Err(err) => HttpResponse::bad_request(&err),
240        },
241        None => HttpResponse::internal_error("Server config validation is not enabled"),
242    }
243}
244
245fn handle_config_mutation(
246    req: &HttpRequest,
247    state: &SharedState,
248    mode: crate::state::ServerConfigMutationMode,
249) -> HttpResponse {
250    let mutator = {
251        let s = state.read().unwrap();
252        s.server_config_mutator.clone()
253    };
254
255    match mutator {
256        Some(mutator) => match mutator(mode, &req.body) {
257            Ok(result) => HttpResponse::ok(json!({ "result": result })),
258            Err(err) => HttpResponse::bad_request(&err),
259        },
260        None => HttpResponse::internal_error("Server config mutation is not enabled"),
261    }
262}
263
264fn handle_info(node: &NodeHandle, state: &SharedState) -> HttpResponse {
265    with_node(node, |n| {
266        let transport_id = match n.query(QueryRequest::TransportIdentity) {
267            Ok(QueryResponse::TransportIdentity(id)) => id,
268            _ => None,
269        };
270        let s = state.read().unwrap();
271        HttpResponse::ok(json!({
272            "transport_id": transport_id.map(|h| to_hex(&h)),
273            "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
274            "uptime_seconds": s.uptime_seconds(),
275        }))
276    })
277}
278
279fn handle_processes(state: &SharedState) -> HttpResponse {
280    let s = state.read().unwrap();
281    let mut processes: Vec<&crate::state::ManagedProcessState> = s.processes.values().collect();
282    processes.sort_by(|a, b| a.name.cmp(&b.name));
283    HttpResponse::ok(json!({
284        "processes": processes
285            .into_iter()
286            .map(|p| json!({
287                "name": p.name,
288                "status": p.status,
289                "ready": p.ready,
290                "ready_state": p.ready_state,
291                "pid": p.pid,
292                "last_exit_code": p.last_exit_code,
293                "restart_count": p.restart_count,
294                "drain_ack_count": p.drain_ack_count,
295                "forced_kill_count": p.forced_kill_count,
296                "last_error": p.last_error,
297                "status_detail": p.status_detail,
298                "durable_log_path": p.durable_log_path,
299                "last_log_age_seconds": p.last_log_age_seconds(),
300                "recent_log_lines": p.recent_log_lines,
301                "uptime_seconds": p.uptime_seconds(),
302                "last_transition_seconds": p.last_transition_seconds(),
303            }))
304            .collect::<Vec<Value>>(),
305    }))
306}
307
308fn handle_process_events(state: &SharedState) -> HttpResponse {
309    let s = state.read().unwrap();
310    let events: Vec<Value> = s
311        .process_events
312        .iter()
313        .rev()
314        .take(20)
315        .map(|event| {
316            json!({
317                "process": event.process,
318                "event": event.event,
319                "detail": event.detail,
320                "age_seconds": event.recorded_at.elapsed().as_secs_f64(),
321            })
322        })
323        .collect();
324    HttpResponse::ok(json!({ "events": events }))
325}
326
327fn handle_process_logs(path: &str, req: &HttpRequest, state: &SharedState) -> HttpResponse {
328    let Some(name) = path
329        .strip_prefix("/api/processes/")
330        .and_then(|rest| rest.strip_suffix("/logs"))
331    else {
332        return HttpResponse::bad_request("Invalid process logs path");
333    };
334
335    let limit = parse_query(&req.query)
336        .get("limit")
337        .and_then(|value| value.parse::<usize>().ok())
338        .map(|value| value.min(500))
339        .unwrap_or(200);
340
341    let s = state.read().unwrap();
342    let Some(logs) = s.process_logs.get(name) else {
343        return HttpResponse::not_found();
344    };
345
346    let lines: Vec<Value> = logs
347        .iter()
348        .rev()
349        .take(limit)
350        .map(|entry| {
351            json!({
352                "process": entry.process,
353                "stream": entry.stream,
354                "line": entry.line,
355                "age_seconds": entry.recorded_at.elapsed().as_secs_f64(),
356            })
357        })
358        .collect();
359
360    HttpResponse::ok(json!({
361        "process": name,
362        "durable_log_path": s.processes.get(name).and_then(|p| p.durable_log_path.clone()),
363        "last_log_age_seconds": s.processes.get(name).and_then(|p| p.last_log_age_seconds()),
364        "recent_log_lines": s.processes.get(name).map(|p| p.recent_log_lines).unwrap_or(0),
365        "lines": lines,
366    }))
367}
368
369fn handle_process_control(path: &str, state: &SharedState, action: &str) -> HttpResponse {
370    let Some(name) = path.strip_prefix("/api/processes/").and_then(|rest| {
371        rest.strip_suffix("/restart")
372            .or_else(|| rest.strip_suffix("/start"))
373            .or_else(|| rest.strip_suffix("/stop"))
374    }) else {
375        return HttpResponse::bad_request("Invalid process control path");
376    };
377
378    let tx = {
379        let s = state.read().unwrap();
380        s.control_tx.clone()
381    };
382
383    match tx {
384        Some(tx) => {
385            let process_name = name.to_string();
386            let command = match action {
387                "restart" => crate::state::ProcessControlCommand::Restart(process_name.clone()),
388                "start" => crate::state::ProcessControlCommand::Start(process_name.clone()),
389                "stop" => crate::state::ProcessControlCommand::Stop(process_name.clone()),
390                _ => return HttpResponse::bad_request("Unknown process action"),
391            };
392            match tx.send(command) {
393                Ok(()) => HttpResponse::ok(json!({
394                    "ok": true,
395                    "queued": true,
396                    "action": action,
397                    "process": process_name,
398                })),
399                Err(_) => HttpResponse::internal_error("Process control channel is unavailable"),
400            }
401        }
402        None => HttpResponse::internal_error("Process control is not enabled"),
403    }
404}
405
406fn handle_interfaces(node: &NodeHandle) -> HttpResponse {
407    with_node(node, |n| match n.query(QueryRequest::InterfaceStats) {
408        Ok(QueryResponse::InterfaceStats(stats)) => {
409            let ifaces: Vec<Value> = stats
410                .interfaces
411                .iter()
412                .map(|i| {
413                    json!({
414                        "id": i.id,
415                        "name": i.name,
416                        "status": if i.status { "up" } else { "down" },
417                        "mode": i.mode,
418                        "interface_type": i.interface_type,
419                        "rxb": i.rxb,
420                        "txb": i.txb,
421                        "rx_packets": i.rx_packets,
422                        "tx_packets": i.tx_packets,
423                        "bitrate": i.bitrate,
424                        "started": i.started,
425                        "ia_freq": i.ia_freq,
426                        "oa_freq": i.oa_freq,
427                    })
428                })
429                .collect();
430            HttpResponse::ok(json!({
431                "interfaces": ifaces,
432                "transport_enabled": stats.transport_enabled,
433                "transport_uptime": stats.transport_uptime,
434                "total_rxb": stats.total_rxb,
435                "total_txb": stats.total_txb,
436            }))
437        }
438        _ => HttpResponse::internal_error("Query failed"),
439    })
440}
441
442fn handle_destinations(node: &NodeHandle, state: &SharedState) -> HttpResponse {
443    with_node(node, |n| match n.query(QueryRequest::LocalDestinations) {
444        Ok(QueryResponse::LocalDestinations(dests)) => {
445            let s = state.read().unwrap();
446            let list: Vec<Value> = dests
447                .iter()
448                .map(|d| {
449                    let name = s
450                        .destinations
451                        .get(&d.hash)
452                        .map(|e| e.full_name.as_str())
453                        .unwrap_or("");
454                    json!({
455                        "hash": to_hex(&d.hash),
456                        "type": d.dest_type,
457                        "name": name,
458                    })
459                })
460                .collect();
461            HttpResponse::ok(json!({"destinations": list}))
462        }
463        _ => HttpResponse::internal_error("Query failed"),
464    })
465}
466
467fn handle_paths(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
468    let params = parse_query(&req.query);
469    let filter_hash: Option<[u8; 16]> = params.get("dest_hash").and_then(|s| hex_to_array(s));
470
471    with_node(node, |n| {
472        match n.query(QueryRequest::PathTable { max_hops: None }) {
473            Ok(QueryResponse::PathTable(paths)) => {
474                let list: Vec<Value> = paths
475                    .iter()
476                    .filter(|p| filter_hash.map_or(true, |h| p.hash == h))
477                    .map(|p| {
478                        json!({
479                            "hash": to_hex(&p.hash),
480                            "via": to_hex(&p.via),
481                            "hops": p.hops,
482                            "expires": p.expires,
483                            "interface": p.interface_name,
484                            "timestamp": p.timestamp,
485                        })
486                    })
487                    .collect();
488                HttpResponse::ok(json!({"paths": list}))
489            }
490            _ => HttpResponse::internal_error("Query failed"),
491        }
492    })
493}
494
495fn handle_links(node: &NodeHandle) -> HttpResponse {
496    with_node(node, |n| match n.query(QueryRequest::Links) {
497        Ok(QueryResponse::Links(links)) => {
498            let list: Vec<Value> = links
499                .iter()
500                .map(|l| {
501                    json!({
502                        "link_id": to_hex(&l.link_id),
503                        "state": l.state,
504                        "is_initiator": l.is_initiator,
505                        "dest_hash": to_hex(&l.dest_hash),
506                        "remote_identity": l.remote_identity.map(|h| to_hex(&h)),
507                        "rtt": l.rtt,
508                        "channel_window": l.channel_window,
509                        "channel_outstanding": l.channel_outstanding,
510                        "pending_channel_packets": l.pending_channel_packets,
511                        "channel_send_ok": l.channel_send_ok,
512                        "channel_send_not_ready": l.channel_send_not_ready,
513                        "channel_send_too_big": l.channel_send_too_big,
514                        "channel_send_other_error": l.channel_send_other_error,
515                        "channel_messages_received": l.channel_messages_received,
516                        "channel_proofs_sent": l.channel_proofs_sent,
517                        "channel_proofs_received": l.channel_proofs_received,
518                    })
519                })
520                .collect();
521            HttpResponse::ok(json!({"links": list}))
522        }
523        _ => HttpResponse::internal_error("Query failed"),
524    })
525}
526
527fn handle_resources(node: &NodeHandle) -> HttpResponse {
528    with_node(node, |n| match n.query(QueryRequest::Resources) {
529        Ok(QueryResponse::Resources(resources)) => {
530            let list: Vec<Value> = resources
531                .iter()
532                .map(|r| {
533                    json!({
534                        "link_id": to_hex(&r.link_id),
535                        "direction": r.direction,
536                        "total_parts": r.total_parts,
537                        "transferred_parts": r.transferred_parts,
538                        "complete": r.complete,
539                    })
540                })
541                .collect();
542            HttpResponse::ok(json!({"resources": list}))
543        }
544        _ => HttpResponse::internal_error("Query failed"),
545    })
546}
547
548fn handle_event_list(req: &HttpRequest, state: &SharedState, kind: &str) -> HttpResponse {
549    let params = parse_query(&req.query);
550    let clear = params.get("clear").map_or(false, |v| v == "true");
551
552    let mut s = state.write().unwrap();
553    let items: Vec<Value> = match kind {
554        "announces" => {
555            let v: Vec<Value> = s
556                .announces
557                .iter()
558                .map(|r| serde_json::to_value(r).unwrap_or_default())
559                .collect();
560            if clear {
561                s.announces.clear();
562            }
563            v
564        }
565        "packets" => {
566            let v: Vec<Value> = s
567                .packets
568                .iter()
569                .map(|r| serde_json::to_value(r).unwrap_or_default())
570                .collect();
571            if clear {
572                s.packets.clear();
573            }
574            v
575        }
576        "proofs" => {
577            let v: Vec<Value> = s
578                .proofs
579                .iter()
580                .map(|r| serde_json::to_value(r).unwrap_or_default())
581                .collect();
582            if clear {
583                s.proofs.clear();
584            }
585            v
586        }
587        "link_events" => {
588            let v: Vec<Value> = s
589                .link_events
590                .iter()
591                .map(|r| serde_json::to_value(r).unwrap_or_default())
592                .collect();
593            if clear {
594                s.link_events.clear();
595            }
596            v
597        }
598        "resource_events" => {
599            let v: Vec<Value> = s
600                .resource_events
601                .iter()
602                .map(|r| serde_json::to_value(r).unwrap_or_default())
603                .collect();
604            if clear {
605                s.resource_events.clear();
606            }
607            v
608        }
609        _ => Vec::new(),
610    };
611
612    let mut obj = serde_json::Map::new();
613    obj.insert(kind.to_string(), Value::Array(items));
614    HttpResponse::ok(Value::Object(obj))
615}
616
617fn handle_recall_identity(hash_str: &str, node: &NodeHandle) -> HttpResponse {
618    let dest_hash: [u8; 16] = match hex_to_array(hash_str) {
619        Some(h) => h,
620        None => return HttpResponse::bad_request("Invalid dest_hash hex (expected 32 hex chars)"),
621    };
622
623    with_node(node, |n| match n.recall_identity(&DestHash(dest_hash)) {
624        Ok(Some(ai)) => HttpResponse::ok(json!({
625            "dest_hash": to_hex(&ai.dest_hash.0),
626            "identity_hash": to_hex(&ai.identity_hash.0),
627            "public_key": to_hex(&ai.public_key),
628            "app_data": ai.app_data.as_ref().map(|d| to_base64(d)),
629            "hops": ai.hops,
630            "received_at": ai.received_at,
631        })),
632        Ok(None) => HttpResponse::not_found(),
633        Err(_) => HttpResponse::internal_error("Query failed"),
634    })
635}
636
637// --- Action handlers ---
638
639fn parse_json_body(req: &HttpRequest) -> Result<Value, HttpResponse> {
640    serde_json::from_slice(&req.body)
641        .map_err(|e| HttpResponse::bad_request(&format!("Invalid JSON: {}", e)))
642}
643
644fn handle_post_destination(
645    req: &HttpRequest,
646    node: &NodeHandle,
647    state: &SharedState,
648) -> HttpResponse {
649    let body = match parse_json_body(req) {
650        Ok(v) => v,
651        Err(r) => return r,
652    };
653
654    let dest_type_str = body["type"].as_str().unwrap_or("");
655    let app_name = match body["app_name"].as_str() {
656        Some(s) => s,
657        None => return HttpResponse::bad_request("Missing app_name"),
658    };
659    let aspects: Vec<&str> = body["aspects"]
660        .as_array()
661        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
662        .unwrap_or_default();
663
664    let (identity_hash, identity_prv_key, identity_pub_key) = {
665        let s = state.read().unwrap();
666        let ih = s.identity_hash;
667        let prv = s.identity.as_ref().and_then(|i| i.get_private_key());
668        let pubk = s.identity.as_ref().and_then(|i| i.get_public_key());
669        (ih, prv, pubk)
670    };
671
672    let (dest, signing_key) = match dest_type_str {
673        "single" => {
674            let direction = body["direction"].as_str().unwrap_or("in");
675            match direction {
676                "in" => {
677                    let ih = match identity_hash {
678                        Some(h) => IdentityHash(h),
679                        None => return HttpResponse::internal_error("No identity loaded"),
680                    };
681                    let dest = Destination::single_in(app_name, &aspects, ih)
682                        .set_proof_strategy(parse_proof_strategy(&body));
683                    (dest, identity_prv_key)
684                }
685                "out" => {
686                    let dh_str = match body["dest_hash"].as_str() {
687                        Some(s) => s,
688                        None => {
689                            return HttpResponse::bad_request(
690                                "OUT single requires dest_hash of remote",
691                            )
692                        }
693                    };
694                    let dh: [u8; 16] = match hex_to_array(dh_str) {
695                        Some(h) => h,
696                        None => return HttpResponse::bad_request("Invalid dest_hash"),
697                    };
698                    return with_node(node, |n| {
699                        match n.recall_identity(&DestHash(dh)) {
700                            Ok(Some(recalled)) => {
701                                let dest = Destination::single_out(app_name, &aspects, &recalled);
702                                // Register in state
703                                let full_name = format_dest_name(app_name, &aspects);
704                                let mut s = state.write().unwrap();
705                                s.destinations.insert(
706                                    dest.hash.0,
707                                    DestinationEntry {
708                                        destination: dest.clone(),
709                                        full_name: full_name.clone(),
710                                    },
711                                );
712                                HttpResponse::created(json!({
713                                    "dest_hash": to_hex(&dest.hash.0),
714                                    "name": full_name,
715                                    "type": "single",
716                                    "direction": "out",
717                                }))
718                            }
719                            Ok(None) => {
720                                HttpResponse::bad_request("No recalled identity for dest_hash")
721                            }
722                            Err(_) => HttpResponse::internal_error("Query failed"),
723                        }
724                    });
725                }
726                _ => return HttpResponse::bad_request("direction must be 'in' or 'out'"),
727            }
728        }
729        "plain" => {
730            let dest = Destination::plain(app_name, &aspects)
731                .set_proof_strategy(parse_proof_strategy(&body));
732            (dest, None)
733        }
734        "group" => {
735            let mut dest = Destination::group(app_name, &aspects)
736                .set_proof_strategy(parse_proof_strategy(&body));
737            if let Some(key_b64) = body["group_key"].as_str() {
738                match from_base64(key_b64) {
739                    Some(key) => {
740                        if let Err(e) = dest.load_private_key(key) {
741                            return HttpResponse::bad_request(&format!("Invalid group key: {}", e));
742                        }
743                    }
744                    None => return HttpResponse::bad_request("Invalid base64 group_key"),
745                }
746            } else {
747                dest.create_keys();
748            }
749            (dest, None)
750        }
751        _ => return HttpResponse::bad_request("type must be 'single', 'plain', or 'group'"),
752    };
753
754    with_node(node, |n| {
755        match n.register_destination_with_proof(&dest, signing_key) {
756            Ok(()) => {
757                // For inbound single dests, also register with link manager
758                // so incoming LINKREQUEST packets are accepted.
759                if dest_type_str == "single" && body["direction"].as_str().unwrap_or("in") == "in" {
760                    if let (Some(prv), Some(pubk)) = (identity_prv_key, identity_pub_key) {
761                        let mut sig_prv = [0u8; 32];
762                        sig_prv.copy_from_slice(&prv[32..64]);
763                        let mut sig_pub = [0u8; 32];
764                        sig_pub.copy_from_slice(&pubk[32..64]);
765                        let _ = n.register_link_destination(dest.hash.0, sig_prv, sig_pub, 0);
766                    }
767                }
768
769                let full_name = format_dest_name(app_name, &aspects);
770                let hash_hex = to_hex(&dest.hash.0);
771                let group_key_b64 = dest.get_private_key().map(to_base64);
772                let mut s = state.write().unwrap();
773                s.destinations.insert(
774                    dest.hash.0,
775                    DestinationEntry {
776                        destination: dest,
777                        full_name: full_name.clone(),
778                    },
779                );
780                let mut resp = json!({
781                    "dest_hash": hash_hex,
782                    "name": full_name,
783                    "type": dest_type_str,
784                });
785                if let Some(gk) = group_key_b64 {
786                    resp["group_key"] = Value::String(gk);
787                }
788                HttpResponse::created(resp)
789            }
790            Err(_) => HttpResponse::internal_error("Failed to register destination"),
791        }
792    })
793}
794
795fn handle_post_announce(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
796    let body = match parse_json_body(req) {
797        Ok(v) => v,
798        Err(r) => return r,
799    };
800
801    let dh_str = match body["dest_hash"].as_str() {
802        Some(s) => s,
803        None => return HttpResponse::bad_request("Missing dest_hash"),
804    };
805    let dh: [u8; 16] = match hex_to_array(dh_str) {
806        Some(h) => h,
807        None => return HttpResponse::bad_request("Invalid dest_hash"),
808    };
809
810    let app_data: Option<Vec<u8>> = body["app_data"].as_str().and_then(from_base64);
811
812    let (dest, identity) = {
813        let s = state.read().unwrap();
814        let dest = match s.destinations.get(&dh) {
815            Some(entry) => entry.destination.clone(),
816            None => return HttpResponse::bad_request("Destination not registered via API"),
817        };
818        let identity = match s.identity.as_ref().and_then(|i| i.get_private_key()) {
819            Some(prv) => Identity::from_private_key(&prv),
820            None => return HttpResponse::internal_error("No identity loaded"),
821        };
822        (dest, identity)
823    };
824
825    with_active_node(node, |n| {
826        match n.announce(&dest, &identity, app_data.as_deref()) {
827            Ok(()) => HttpResponse::ok(json!({"status": "announced", "dest_hash": dh_str})),
828            Err(_) => HttpResponse::internal_error("Announce failed"),
829        }
830    })
831}
832
833fn handle_post_send(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
834    let body = match parse_json_body(req) {
835        Ok(v) => v,
836        Err(r) => return r,
837    };
838
839    let dh_str = match body["dest_hash"].as_str() {
840        Some(s) => s,
841        None => return HttpResponse::bad_request("Missing dest_hash"),
842    };
843    let dh: [u8; 16] = match hex_to_array(dh_str) {
844        Some(h) => h,
845        None => return HttpResponse::bad_request("Invalid dest_hash"),
846    };
847    let data = match body["data"].as_str().and_then(from_base64) {
848        Some(d) => d,
849        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
850    };
851
852    let s = state.read().unwrap();
853    let dest = match s.destinations.get(&dh) {
854        Some(entry) => entry.destination.clone(),
855        None => return HttpResponse::bad_request("Destination not registered via API"),
856    };
857    drop(s);
858
859    let max_len = match dest.dest_type {
860        rns_core::types::DestinationType::Plain => rns_core::constants::PLAIN_MDU,
861        rns_core::types::DestinationType::Single | rns_core::types::DestinationType::Group => {
862            rns_core::constants::ENCRYPTED_MDU
863        }
864    };
865    if data.len() > max_len {
866        return HttpResponse::bad_request(&format!(
867            "Payload too large for single-packet send: {} bytes > {} byte limit",
868            data.len(),
869            max_len
870        ));
871    }
872
873    with_active_node(node, |n| match n.send_packet(&dest, &data) {
874        Ok(ph) => HttpResponse::ok(json!({
875            "status": "sent",
876            "packet_hash": to_hex(&ph.0),
877        })),
878        Err(_) => HttpResponse::internal_error("Send failed"),
879    })
880}
881
882fn handle_post_link(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
883    let body = match parse_json_body(req) {
884        Ok(v) => v,
885        Err(r) => return r,
886    };
887
888    let dh_str = match body["dest_hash"].as_str() {
889        Some(s) => s,
890        None => return HttpResponse::bad_request("Missing dest_hash"),
891    };
892    let dh: [u8; 16] = match hex_to_array(dh_str) {
893        Some(h) => h,
894        None => return HttpResponse::bad_request("Invalid dest_hash"),
895    };
896
897    with_active_node(node, |n| {
898        // Recall identity to get signing public key
899        let recalled = match n.recall_identity(&DestHash(dh)) {
900            Ok(Some(ai)) => ai,
901            Ok(None) => return HttpResponse::bad_request("No recalled identity for dest_hash"),
902            Err(_) => return HttpResponse::internal_error("Query failed"),
903        };
904        // Extract Ed25519 public key (second 32 bytes of public_key)
905        let mut sig_pub = [0u8; 32];
906        sig_pub.copy_from_slice(&recalled.public_key[32..64]);
907
908        match n.create_link(dh, sig_pub) {
909            Ok(link_id) => HttpResponse::created(json!({
910                "link_id": to_hex(&link_id),
911            })),
912            Err(_) => HttpResponse::internal_error("Create link failed"),
913        }
914    })
915}
916
917fn handle_post_link_send(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
918    let body = match parse_json_body(req) {
919        Ok(v) => v,
920        Err(r) => return r,
921    };
922
923    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
924        Some(h) => h,
925        None => return HttpResponse::bad_request("Missing or invalid link_id"),
926    };
927    let data = match body["data"].as_str().and_then(from_base64) {
928        Some(d) => d,
929        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
930    };
931    let context = body["context"].as_u64().unwrap_or(0) as u8;
932
933    with_active_node(node, |n| match n.send_on_link(link_id, data, context) {
934        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
935        Err(_) => HttpResponse::internal_error("Send on link failed"),
936    })
937}
938
939fn handle_post_link_close(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
940    let body = match parse_json_body(req) {
941        Ok(v) => v,
942        Err(r) => return r,
943    };
944
945    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
946        Some(h) => h,
947        None => return HttpResponse::bad_request("Missing or invalid link_id"),
948    };
949
950    with_node(node, |n| match n.teardown_link(link_id) {
951        Ok(()) => HttpResponse::ok(json!({"status": "closed"})),
952        Err(_) => HttpResponse::internal_error("Teardown link failed"),
953    })
954}
955
956fn handle_post_channel(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
957    let body = match parse_json_body(req) {
958        Ok(v) => v,
959        Err(r) => return r,
960    };
961
962    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
963        Some(h) => h,
964        None => return HttpResponse::bad_request("Missing or invalid link_id"),
965    };
966    let msgtype = body["msgtype"].as_u64().unwrap_or(0) as u16;
967    let payload = match body["payload"].as_str().and_then(from_base64) {
968        Some(d) => d,
969        None => return HttpResponse::bad_request("Missing or invalid base64 payload"),
970    };
971
972    with_active_node(node, |n| {
973        match n.send_channel_message(link_id, msgtype, payload) {
974            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
975            Err(_) => HttpResponse::bad_request("Channel message failed"),
976        }
977    })
978}
979
980fn handle_post_resource(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
981    let body = match parse_json_body(req) {
982        Ok(v) => v,
983        Err(r) => return r,
984    };
985
986    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
987        Some(h) => h,
988        None => return HttpResponse::bad_request("Missing or invalid link_id"),
989    };
990    let data = match body["data"].as_str().and_then(from_base64) {
991        Some(d) => d,
992        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
993    };
994    let metadata = body["metadata"].as_str().and_then(from_base64);
995
996    with_active_node(node, |n| match n.send_resource(link_id, data, metadata) {
997        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
998        Err(_) => HttpResponse::internal_error("Resource send failed"),
999    })
1000}
1001
1002fn handle_post_path_request(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1003    let body = match parse_json_body(req) {
1004        Ok(v) => v,
1005        Err(r) => return r,
1006    };
1007
1008    let dh_str = match body["dest_hash"].as_str() {
1009        Some(s) => s,
1010        None => return HttpResponse::bad_request("Missing dest_hash"),
1011    };
1012    let dh: [u8; 16] = match hex_to_array(dh_str) {
1013        Some(h) => h,
1014        None => return HttpResponse::bad_request("Invalid dest_hash"),
1015    };
1016
1017    with_active_node(node, |n| match n.request_path(&DestHash(dh)) {
1018        Ok(()) => HttpResponse::ok(json!({"status": "requested"})),
1019        Err(_) => HttpResponse::internal_error("Path request failed"),
1020    })
1021}
1022
1023fn handle_post_direct_connect(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1024    let body = match parse_json_body(req) {
1025        Ok(v) => v,
1026        Err(r) => return r,
1027    };
1028
1029    let lid_str = match body["link_id"].as_str() {
1030        Some(s) => s,
1031        None => return HttpResponse::bad_request("Missing link_id"),
1032    };
1033    let link_id: [u8; 16] = match hex_to_array(lid_str) {
1034        Some(h) => h,
1035        None => return HttpResponse::bad_request("Invalid link_id"),
1036    };
1037
1038    with_active_node(node, |n| match n.propose_direct_connect(link_id) {
1039        Ok(()) => HttpResponse::ok(json!({"status": "proposed"})),
1040        Err(_) => HttpResponse::internal_error("Direct connect proposal failed"),
1041    })
1042}
1043
1044fn handle_post_clear_announce_queues(node: &NodeHandle) -> HttpResponse {
1045    with_node(node, |n| match n.query(QueryRequest::DropAnnounceQueues) {
1046        Ok(QueryResponse::DropAnnounceQueues) => HttpResponse::ok(json!({"status": "ok"})),
1047        _ => HttpResponse::internal_error("Query failed"),
1048    })
1049}
1050
1051// --- Backbone peer state handlers ---
1052
1053fn handle_backbone_peers(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1054    let params = parse_query(&req.path);
1055    let interface_name = params.get("interface").map(|s| s.to_string());
1056    with_node(node, |n| {
1057        match n.query(QueryRequest::BackbonePeerState { interface_name }) {
1058            Ok(QueryResponse::BackbonePeerState(entries)) => {
1059                let peers: Vec<Value> = entries
1060                    .iter()
1061                    .map(|e| {
1062                        json!({
1063                            "interface": e.interface_name,
1064                            "ip": e.peer_ip.to_string(),
1065                            "connected_count": e.connected_count,
1066                            "blacklisted_remaining_secs": e.blacklisted_remaining_secs,
1067                            "blacklist_reason": e.blacklist_reason,
1068                            "reject_count": e.reject_count,
1069                        })
1070                    })
1071                    .collect();
1072                HttpResponse::ok(json!({ "peers": peers }))
1073            }
1074            _ => HttpResponse::internal_error("Query failed"),
1075        }
1076    })
1077}
1078
1079fn handle_backbone_blacklist(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1080    let body: Value = match serde_json::from_slice(&req.body) {
1081        Ok(v) => v,
1082        Err(_) => return HttpResponse::bad_request("Invalid JSON body"),
1083    };
1084    let interface_name = match body.get("interface").and_then(|v| v.as_str()) {
1085        Some(s) => s.to_string(),
1086        None => return HttpResponse::bad_request("Missing 'interface' field"),
1087    };
1088    let ip = match body.get("ip").and_then(|v| v.as_str()) {
1089        Some(s) => match s.parse::<std::net::IpAddr>() {
1090            Ok(addr) => addr,
1091            Err(_) => return HttpResponse::bad_request("Invalid IP address"),
1092        },
1093        None => return HttpResponse::bad_request("Missing 'ip' field"),
1094    };
1095    let duration_secs = match body.get("duration_secs").and_then(|v| v.as_u64()) {
1096        Some(d) => d,
1097        None => return HttpResponse::bad_request("Missing 'duration_secs' field"),
1098    };
1099    let reason = body
1100        .get("reason")
1101        .and_then(|v| v.as_str())
1102        .unwrap_or("sentinel blacklist")
1103        .to_string();
1104    let penalty_level = body
1105        .get("penalty_level")
1106        .and_then(|v| v.as_u64())
1107        .unwrap_or(0)
1108        .min(u8::MAX as u64) as u8;
1109    with_node(node, |n| {
1110        match n.query(QueryRequest::BlacklistBackbonePeer {
1111            interface_name,
1112            peer_ip: ip,
1113            duration: std::time::Duration::from_secs(duration_secs),
1114            reason,
1115            penalty_level,
1116        }) {
1117            Ok(QueryResponse::BlacklistBackbonePeer(true)) => {
1118                HttpResponse::ok(json!({"status": "ok"}))
1119            }
1120            Ok(QueryResponse::BlacklistBackbonePeer(false)) => HttpResponse::not_found(),
1121            _ => HttpResponse::internal_error("Query failed"),
1122        }
1123    })
1124}
1125
1126// --- Hook handlers ---
1127
1128fn handle_list_hooks(node: &NodeHandle) -> HttpResponse {
1129    with_node(node, |n| match n.list_hooks() {
1130        Ok(hooks) => {
1131            let list: Vec<Value> = hooks
1132                .iter()
1133                .map(|h| {
1134                    json!({
1135                        "name": h.name,
1136                        "attach_point": h.attach_point,
1137                        "priority": h.priority,
1138                        "enabled": h.enabled,
1139                        "consecutive_traps": h.consecutive_traps,
1140                    })
1141                })
1142                .collect();
1143            HttpResponse::ok(json!({"hooks": list}))
1144        }
1145        Err(_) => HttpResponse::internal_error("Query failed"),
1146    })
1147}
1148
1149/// Load a WASM hook from a filesystem path.
1150///
1151/// The `path` field in the JSON body refers to a file on the **server's** local
1152/// filesystem. This means the CLI and the HTTP server must have access to the
1153/// same filesystem for the path to resolve correctly.
1154fn handle_load_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1155    let body = match parse_json_body(req) {
1156        Ok(v) => v,
1157        Err(r) => return r,
1158    };
1159
1160    let name = match body["name"].as_str() {
1161        Some(s) => s.to_string(),
1162        None => return HttpResponse::bad_request("Missing name"),
1163    };
1164    let path = match body["path"].as_str() {
1165        Some(s) => s,
1166        None => return HttpResponse::bad_request("Missing path"),
1167    };
1168    let attach_point = match body["attach_point"].as_str() {
1169        Some(s) => s.to_string(),
1170        None => return HttpResponse::bad_request("Missing attach_point"),
1171    };
1172    let priority = body["priority"].as_i64().unwrap_or(0) as i32;
1173
1174    // Read WASM file
1175    let wasm_bytes = match std::fs::read(path) {
1176        Ok(b) => b,
1177        Err(e) => return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e)),
1178    };
1179
1180    with_node(node, |n| {
1181        match n.load_hook(name, wasm_bytes, attach_point, priority) {
1182            Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1183            Ok(Err(e)) => HttpResponse::bad_request(&e),
1184            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1185        }
1186    })
1187}
1188
1189fn handle_unload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1190    let body = match parse_json_body(req) {
1191        Ok(v) => v,
1192        Err(r) => return r,
1193    };
1194
1195    let name = match body["name"].as_str() {
1196        Some(s) => s.to_string(),
1197        None => return HttpResponse::bad_request("Missing name"),
1198    };
1199    let attach_point = match body["attach_point"].as_str() {
1200        Some(s) => s.to_string(),
1201        None => return HttpResponse::bad_request("Missing attach_point"),
1202    };
1203
1204    with_node(node, |n| match n.unload_hook(name, attach_point) {
1205        Ok(Ok(())) => HttpResponse::ok(json!({"status": "unloaded"})),
1206        Ok(Err(e)) => HttpResponse::bad_request(&e),
1207        Err(_) => HttpResponse::internal_error("Driver unavailable"),
1208    })
1209}
1210
1211fn handle_reload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1212    let body = match parse_json_body(req) {
1213        Ok(v) => v,
1214        Err(r) => return r,
1215    };
1216
1217    let name = match body["name"].as_str() {
1218        Some(s) => s.to_string(),
1219        None => return HttpResponse::bad_request("Missing name"),
1220    };
1221    let path = match body["path"].as_str() {
1222        Some(s) => s,
1223        None => return HttpResponse::bad_request("Missing path"),
1224    };
1225    let attach_point = match body["attach_point"].as_str() {
1226        Some(s) => s.to_string(),
1227        None => return HttpResponse::bad_request("Missing attach_point"),
1228    };
1229
1230    let wasm_bytes = match std::fs::read(path) {
1231        Ok(b) => b,
1232        Err(e) => return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e)),
1233    };
1234
1235    with_node(node, |n| {
1236        match n.reload_hook(name, attach_point, wasm_bytes) {
1237            Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1238            Ok(Err(e)) => HttpResponse::bad_request(&e),
1239            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1240        }
1241    })
1242}
1243
1244fn handle_set_hook_enabled(req: &HttpRequest, node: &NodeHandle, enabled: bool) -> HttpResponse {
1245    let body = match parse_json_body(req) {
1246        Ok(v) => v,
1247        Err(r) => return r,
1248    };
1249
1250    let name = match body["name"].as_str() {
1251        Some(s) => s.to_string(),
1252        None => return HttpResponse::bad_request("Missing name"),
1253    };
1254    let attach_point = match body["attach_point"].as_str() {
1255        Some(s) => s.to_string(),
1256        None => return HttpResponse::bad_request("Missing attach_point"),
1257    };
1258
1259    with_node(node, |n| {
1260        match n.set_hook_enabled(name, attach_point, enabled) {
1261            Ok(Ok(())) => HttpResponse::ok(json!({
1262                "status": if enabled { "enabled" } else { "disabled" }
1263            })),
1264            Ok(Err(e)) => HttpResponse::bad_request(&e),
1265            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1266        }
1267    })
1268}
1269
1270fn handle_set_hook_priority(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1271    let body = match parse_json_body(req) {
1272        Ok(v) => v,
1273        Err(r) => return r,
1274    };
1275
1276    let name = match body["name"].as_str() {
1277        Some(s) => s.to_string(),
1278        None => return HttpResponse::bad_request("Missing name"),
1279    };
1280    let attach_point = match body["attach_point"].as_str() {
1281        Some(s) => s.to_string(),
1282        None => return HttpResponse::bad_request("Missing attach_point"),
1283    };
1284    let priority = match body["priority"].as_i64() {
1285        Some(v) => v as i32,
1286        None => return HttpResponse::bad_request("Missing priority"),
1287    };
1288
1289    with_node(node, |n| {
1290        match n.set_hook_priority(name, attach_point, priority) {
1291            Ok(Ok(())) => HttpResponse::ok(json!({"status": "priority_updated"})),
1292            Ok(Err(e)) => HttpResponse::bad_request(&e),
1293            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1294        }
1295    })
1296}
1297
1298// --- Helpers ---
1299
1300fn format_dest_name(app_name: &str, aspects: &[&str]) -> String {
1301    if aspects.is_empty() {
1302        app_name.to_string()
1303    } else {
1304        format!("{}.{}", app_name, aspects.join("."))
1305    }
1306}
1307
1308fn parse_proof_strategy(body: &Value) -> ProofStrategy {
1309    match body["proof_strategy"].as_str() {
1310        Some("all") => ProofStrategy::ProveAll,
1311        Some("app") => ProofStrategy::ProveApp,
1312        _ => ProofStrategy::ProveNone,
1313    }
1314}