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