Skip to main content

rns_ctl/
api.rs

1use serde_json::{json, Value};
2
3use rns_net::{
4    Destination, QueryRequest, QueryResponse, RnsNode,
5    DestHash, IdentityHash, ProofStrategy,
6};
7use rns_crypto::identity::Identity;
8
9use crate::auth::check_auth;
10use crate::config::CtlConfig;
11use crate::encode::{from_base64, hex_to_array, to_base64, to_hex};
12use crate::http::{parse_query, HttpRequest, HttpResponse};
13use crate::state::{DestinationEntry, SharedState};
14
15/// Handle for the node, wrapped so shutdown() can consume it.
16pub type NodeHandle = std::sync::Arc<std::sync::Mutex<Option<RnsNode>>>;
17
18/// Execute a closure with a reference to the node, returning an error response if the node is gone.
19fn with_node<F>(node: &NodeHandle, f: F) -> HttpResponse
20where
21    F: FnOnce(&RnsNode) -> HttpResponse,
22{
23    let guard = node.lock().unwrap();
24    match guard.as_ref() {
25        Some(n) => f(n),
26        None => HttpResponse::internal_error("Node is shutting down"),
27    }
28}
29
30/// Route dispatch: match method + path and call the appropriate handler.
31pub fn handle_request(
32    req: &HttpRequest,
33    node: &NodeHandle,
34    state: &SharedState,
35    config: &CtlConfig,
36) -> HttpResponse {
37    // Health check — no auth required
38    if req.method == "GET" && req.path == "/health" {
39        return HttpResponse::ok(json!({"status": "healthy"}));
40    }
41
42    // All other endpoints require auth
43    if let Err(resp) = check_auth(req, config) {
44        return resp;
45    }
46
47    match (req.method.as_str(), req.path.as_str()) {
48        // Read endpoints
49        ("GET", "/api/info") => handle_info(node, state),
50        ("GET", "/api/interfaces") => handle_interfaces(node),
51        ("GET", "/api/destinations") => handle_destinations(node, state),
52        ("GET", "/api/paths") => handle_paths(req, node),
53        ("GET", "/api/links") => handle_links(node),
54        ("GET", "/api/resources") => handle_resources(node),
55        ("GET", "/api/announces") => handle_event_list(req, state, "announces"),
56        ("GET", "/api/packets") => handle_event_list(req, state, "packets"),
57        ("GET", "/api/proofs") => handle_event_list(req, state, "proofs"),
58        ("GET", "/api/link_events") => handle_event_list(req, state, "link_events"),
59        ("GET", "/api/resource_events") => handle_event_list(req, state, "resource_events"),
60
61        // Identity recall: /api/identity/<dest_hash>
62        ("GET", path) if path.starts_with("/api/identity/") => {
63            let hash_str = &path["/api/identity/".len()..];
64            handle_recall_identity(hash_str, node)
65        }
66
67        // Action endpoints
68        ("POST", "/api/destination") => handle_post_destination(req, node, state),
69        ("POST", "/api/announce") => handle_post_announce(req, node, state),
70        ("POST", "/api/send") => handle_post_send(req, node, state),
71        ("POST", "/api/link") => handle_post_link(req, node),
72        ("POST", "/api/link/send") => handle_post_link_send(req, node),
73        ("POST", "/api/link/close") => handle_post_link_close(req, node),
74        ("POST", "/api/channel") => handle_post_channel(req, node),
75        ("POST", "/api/resource") => handle_post_resource(req, node),
76        ("POST", "/api/path/request") => handle_post_path_request(req, node),
77
78        _ => HttpResponse::not_found(),
79    }
80}
81
82// --- Read handlers ---
83
84fn handle_info(node: &NodeHandle, state: &SharedState) -> HttpResponse {
85    with_node(node, |n| {
86        let transport_id = match n.query(QueryRequest::TransportIdentity) {
87            Ok(QueryResponse::TransportIdentity(id)) => id,
88            _ => None,
89        };
90        let s = state.read().unwrap();
91        HttpResponse::ok(json!({
92            "transport_id": transport_id.map(|h| to_hex(&h)),
93            "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
94            "uptime_seconds": s.uptime_seconds(),
95        }))
96    })
97}
98
99fn handle_interfaces(node: &NodeHandle) -> HttpResponse {
100    with_node(node, |n| {
101        match n.query(QueryRequest::InterfaceStats) {
102            Ok(QueryResponse::InterfaceStats(stats)) => {
103                let ifaces: Vec<Value> = stats
104                    .interfaces
105                    .iter()
106                    .map(|i| {
107                        json!({
108                            "name": i.name,
109                            "status": if i.status { "up" } else { "down" },
110                            "mode": i.mode,
111                            "interface_type": i.interface_type,
112                            "rxb": i.rxb,
113                            "txb": i.txb,
114                            "rx_packets": i.rx_packets,
115                            "tx_packets": i.tx_packets,
116                            "bitrate": i.bitrate,
117                            "started": i.started,
118                            "ia_freq": i.ia_freq,
119                            "oa_freq": i.oa_freq,
120                        })
121                    })
122                    .collect();
123                HttpResponse::ok(json!({
124                    "interfaces": ifaces,
125                    "transport_enabled": stats.transport_enabled,
126                    "transport_uptime": stats.transport_uptime,
127                    "total_rxb": stats.total_rxb,
128                    "total_txb": stats.total_txb,
129                }))
130            }
131            _ => HttpResponse::internal_error("Query failed"),
132        }
133    })
134}
135
136fn handle_destinations(node: &NodeHandle, state: &SharedState) -> HttpResponse {
137    with_node(node, |n| {
138        match n.query(QueryRequest::LocalDestinations) {
139            Ok(QueryResponse::LocalDestinations(dests)) => {
140                let s = state.read().unwrap();
141                let list: Vec<Value> = dests
142                    .iter()
143                    .map(|d| {
144                        let name = s
145                            .destinations
146                            .get(&d.hash)
147                            .map(|e| e.full_name.as_str())
148                            .unwrap_or("");
149                        json!({
150                            "hash": to_hex(&d.hash),
151                            "type": d.dest_type,
152                            "name": name,
153                        })
154                    })
155                    .collect();
156                HttpResponse::ok(json!({"destinations": list}))
157            }
158            _ => HttpResponse::internal_error("Query failed"),
159        }
160    })
161}
162
163fn handle_paths(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
164    let params = parse_query(&req.query);
165    let filter_hash: Option<[u8; 16]> = params
166        .get("dest_hash")
167        .and_then(|s| hex_to_array(s));
168
169    with_node(node, |n| {
170        match n.query(QueryRequest::PathTable { max_hops: None }) {
171            Ok(QueryResponse::PathTable(paths)) => {
172                let list: Vec<Value> = paths
173                    .iter()
174                    .filter(|p| filter_hash.map_or(true, |h| p.hash == h))
175                    .map(|p| {
176                        json!({
177                            "hash": to_hex(&p.hash),
178                            "via": to_hex(&p.via),
179                            "hops": p.hops,
180                            "expires": p.expires,
181                            "interface": p.interface_name,
182                            "timestamp": p.timestamp,
183                        })
184                    })
185                    .collect();
186                HttpResponse::ok(json!({"paths": list}))
187            }
188            _ => HttpResponse::internal_error("Query failed"),
189        }
190    })
191}
192
193fn handle_links(node: &NodeHandle) -> HttpResponse {
194    with_node(node, |n| {
195        match n.query(QueryRequest::Links) {
196            Ok(QueryResponse::Links(links)) => {
197                let list: Vec<Value> = links
198                    .iter()
199                    .map(|l| {
200                        json!({
201                            "link_id": to_hex(&l.link_id),
202                            "state": l.state,
203                            "is_initiator": l.is_initiator,
204                            "dest_hash": to_hex(&l.dest_hash),
205                            "remote_identity": l.remote_identity.map(|h| to_hex(&h)),
206                            "rtt": l.rtt,
207                        })
208                    })
209                    .collect();
210                HttpResponse::ok(json!({"links": list}))
211            }
212            _ => HttpResponse::internal_error("Query failed"),
213        }
214    })
215}
216
217fn handle_resources(node: &NodeHandle) -> HttpResponse {
218    with_node(node, |n| {
219        match n.query(QueryRequest::Resources) {
220            Ok(QueryResponse::Resources(resources)) => {
221                let list: Vec<Value> = resources
222                    .iter()
223                    .map(|r| {
224                        json!({
225                            "link_id": to_hex(&r.link_id),
226                            "direction": r.direction,
227                            "total_parts": r.total_parts,
228                            "transferred_parts": r.transferred_parts,
229                            "complete": r.complete,
230                        })
231                    })
232                    .collect();
233                HttpResponse::ok(json!({"resources": list}))
234            }
235            _ => HttpResponse::internal_error("Query failed"),
236        }
237    })
238}
239
240fn handle_event_list(req: &HttpRequest, state: &SharedState, kind: &str) -> HttpResponse {
241    let params = parse_query(&req.query);
242    let clear = params.get("clear").map_or(false, |v| v == "true");
243
244    let mut s = state.write().unwrap();
245    let items: Vec<Value> = match kind {
246        "announces" => {
247            let v: Vec<Value> = s
248                .announces
249                .iter()
250                .map(|r| serde_json::to_value(r).unwrap_or_default())
251                .collect();
252            if clear {
253                s.announces.clear();
254            }
255            v
256        }
257        "packets" => {
258            let v: Vec<Value> = s
259                .packets
260                .iter()
261                .map(|r| serde_json::to_value(r).unwrap_or_default())
262                .collect();
263            if clear {
264                s.packets.clear();
265            }
266            v
267        }
268        "proofs" => {
269            let v: Vec<Value> = s
270                .proofs
271                .iter()
272                .map(|r| serde_json::to_value(r).unwrap_or_default())
273                .collect();
274            if clear {
275                s.proofs.clear();
276            }
277            v
278        }
279        "link_events" => {
280            let v: Vec<Value> = s
281                .link_events
282                .iter()
283                .map(|r| serde_json::to_value(r).unwrap_or_default())
284                .collect();
285            if clear {
286                s.link_events.clear();
287            }
288            v
289        }
290        "resource_events" => {
291            let v: Vec<Value> = s
292                .resource_events
293                .iter()
294                .map(|r| serde_json::to_value(r).unwrap_or_default())
295                .collect();
296            if clear {
297                s.resource_events.clear();
298            }
299            v
300        }
301        _ => Vec::new(),
302    };
303
304    let mut obj = serde_json::Map::new();
305    obj.insert(kind.to_string(), Value::Array(items));
306    HttpResponse::ok(Value::Object(obj))
307}
308
309fn handle_recall_identity(hash_str: &str, node: &NodeHandle) -> HttpResponse {
310    let dest_hash: [u8; 16] = match hex_to_array(hash_str) {
311        Some(h) => h,
312        None => return HttpResponse::bad_request("Invalid dest_hash hex (expected 32 hex chars)"),
313    };
314
315    with_node(node, |n| {
316        match n.recall_identity(&DestHash(dest_hash)) {
317            Ok(Some(ai)) => HttpResponse::ok(json!({
318                "dest_hash": to_hex(&ai.dest_hash.0),
319                "identity_hash": to_hex(&ai.identity_hash.0),
320                "public_key": to_hex(&ai.public_key),
321                "app_data": ai.app_data.as_ref().map(|d| to_base64(d)),
322                "hops": ai.hops,
323                "received_at": ai.received_at,
324            })),
325            Ok(None) => HttpResponse::not_found(),
326            Err(_) => HttpResponse::internal_error("Query failed"),
327        }
328    })
329}
330
331// --- Action handlers ---
332
333fn parse_json_body(req: &HttpRequest) -> Result<Value, HttpResponse> {
334    serde_json::from_slice(&req.body).map_err(|e| HttpResponse::bad_request(&format!("Invalid JSON: {}", e)))
335}
336
337fn handle_post_destination(
338    req: &HttpRequest,
339    node: &NodeHandle,
340    state: &SharedState,
341) -> HttpResponse {
342    let body = match parse_json_body(req) {
343        Ok(v) => v,
344        Err(r) => return r,
345    };
346
347    let dest_type_str = body["type"].as_str().unwrap_or("");
348    let app_name = match body["app_name"].as_str() {
349        Some(s) => s,
350        None => return HttpResponse::bad_request("Missing app_name"),
351    };
352    let aspects: Vec<&str> = body["aspects"]
353        .as_array()
354        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
355        .unwrap_or_default();
356
357    let (identity_hash, identity_prv_key, identity_pub_key) = {
358        let s = state.read().unwrap();
359        let ih = s.identity_hash;
360        let prv = s.identity.as_ref().and_then(|i| i.get_private_key());
361        let pubk = s.identity.as_ref().and_then(|i| i.get_public_key());
362        (ih, prv, pubk)
363    };
364
365    let (dest, signing_key) = match dest_type_str {
366        "single" => {
367            let direction = body["direction"].as_str().unwrap_or("in");
368            match direction {
369                "in" => {
370                    let ih = match identity_hash {
371                        Some(h) => IdentityHash(h),
372                        None => return HttpResponse::internal_error("No identity loaded"),
373                    };
374                    let dest = Destination::single_in(app_name, &aspects, ih)
375                        .set_proof_strategy(parse_proof_strategy(&body));
376                    (dest, identity_prv_key)
377                }
378                "out" => {
379                    let dh_str = match body["dest_hash"].as_str() {
380                        Some(s) => s,
381                        None => return HttpResponse::bad_request("OUT single requires dest_hash of remote"),
382                    };
383                    let dh: [u8; 16] = match hex_to_array(dh_str) {
384                        Some(h) => h,
385                        None => return HttpResponse::bad_request("Invalid dest_hash"),
386                    };
387                    return with_node(node, |n| {
388                        match n.recall_identity(&DestHash(dh)) {
389                            Ok(Some(recalled)) => {
390                                let dest = Destination::single_out(app_name, &aspects, &recalled);
391                                // Register in state
392                                let full_name = format_dest_name(app_name, &aspects);
393                                let mut s = state.write().unwrap();
394                                s.destinations.insert(dest.hash.0, DestinationEntry {
395                                    destination: dest.clone(),
396                                    full_name: full_name.clone(),
397                                });
398                                HttpResponse::created(json!({
399                                    "dest_hash": to_hex(&dest.hash.0),
400                                    "name": full_name,
401                                    "type": "single",
402                                    "direction": "out",
403                                }))
404                            }
405                            Ok(None) => HttpResponse::bad_request("No recalled identity for dest_hash"),
406                            Err(_) => HttpResponse::internal_error("Query failed"),
407                        }
408                    });
409                }
410                _ => return HttpResponse::bad_request("direction must be 'in' or 'out'"),
411            }
412        }
413        "plain" => {
414            let dest = Destination::plain(app_name, &aspects)
415                .set_proof_strategy(parse_proof_strategy(&body));
416            (dest, None)
417        }
418        "group" => {
419            let mut dest = Destination::group(app_name, &aspects)
420                .set_proof_strategy(parse_proof_strategy(&body));
421            if let Some(key_b64) = body["group_key"].as_str() {
422                match from_base64(key_b64) {
423                    Some(key) => {
424                        if let Err(e) = dest.load_private_key(key) {
425                            return HttpResponse::bad_request(&format!("Invalid group key: {}", e));
426                        }
427                    }
428                    None => return HttpResponse::bad_request("Invalid base64 group_key"),
429                }
430            } else {
431                dest.create_keys();
432            }
433            (dest, None)
434        }
435        _ => return HttpResponse::bad_request("type must be 'single', 'plain', or 'group'"),
436    };
437
438    with_node(node, |n| {
439        match n.register_destination_with_proof(&dest, signing_key) {
440            Ok(()) => {
441                // For inbound single dests, also register with link manager
442                // so incoming LINKREQUEST packets are accepted.
443                if dest_type_str == "single"
444                    && body["direction"].as_str().unwrap_or("in") == "in"
445                {
446                    if let (Some(prv), Some(pubk)) = (identity_prv_key, identity_pub_key) {
447                        let mut sig_prv = [0u8; 32];
448                        sig_prv.copy_from_slice(&prv[32..64]);
449                        let mut sig_pub = [0u8; 32];
450                        sig_pub.copy_from_slice(&pubk[32..64]);
451                        let _ = n.register_link_destination(dest.hash.0, sig_prv, sig_pub);
452                    }
453                }
454
455                let full_name = format_dest_name(app_name, &aspects);
456                let hash_hex = to_hex(&dest.hash.0);
457                let group_key_b64 = dest.get_private_key().map(to_base64);
458                let mut s = state.write().unwrap();
459                s.destinations.insert(
460                    dest.hash.0,
461                    DestinationEntry {
462                        destination: dest,
463                        full_name: full_name.clone(),
464                    },
465                );
466                let mut resp = json!({
467                    "dest_hash": hash_hex,
468                    "name": full_name,
469                    "type": dest_type_str,
470                });
471                if let Some(gk) = group_key_b64 {
472                    resp["group_key"] = Value::String(gk);
473                }
474                HttpResponse::created(resp)
475            }
476            Err(_) => HttpResponse::internal_error("Failed to register destination"),
477        }
478    })
479}
480
481fn handle_post_announce(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
482    let body = match parse_json_body(req) {
483        Ok(v) => v,
484        Err(r) => return r,
485    };
486
487    let dh_str = match body["dest_hash"].as_str() {
488        Some(s) => s,
489        None => return HttpResponse::bad_request("Missing dest_hash"),
490    };
491    let dh: [u8; 16] = match hex_to_array(dh_str) {
492        Some(h) => h,
493        None => return HttpResponse::bad_request("Invalid dest_hash"),
494    };
495
496    let app_data: Option<Vec<u8>> = body["app_data"]
497        .as_str()
498        .and_then(from_base64);
499
500    let (dest, identity) = {
501        let s = state.read().unwrap();
502        let dest = match s.destinations.get(&dh) {
503            Some(entry) => entry.destination.clone(),
504            None => return HttpResponse::bad_request("Destination not registered via API"),
505        };
506        let identity = match s.identity.as_ref().and_then(|i| i.get_private_key()) {
507            Some(prv) => Identity::from_private_key(&prv),
508            None => return HttpResponse::internal_error("No identity loaded"),
509        };
510        (dest, identity)
511    };
512
513    with_node(node, |n| {
514        match n.announce(&dest, &identity, app_data.as_deref()) {
515            Ok(()) => HttpResponse::ok(json!({"status": "announced", "dest_hash": dh_str})),
516            Err(_) => HttpResponse::internal_error("Announce failed"),
517        }
518    })
519}
520
521fn handle_post_send(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
522    let body = match parse_json_body(req) {
523        Ok(v) => v,
524        Err(r) => return r,
525    };
526
527    let dh_str = match body["dest_hash"].as_str() {
528        Some(s) => s,
529        None => return HttpResponse::bad_request("Missing dest_hash"),
530    };
531    let dh: [u8; 16] = match hex_to_array(dh_str) {
532        Some(h) => h,
533        None => return HttpResponse::bad_request("Invalid dest_hash"),
534    };
535    let data = match body["data"].as_str().and_then(from_base64) {
536        Some(d) => d,
537        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
538    };
539
540    let s = state.read().unwrap();
541    let dest = match s.destinations.get(&dh) {
542        Some(entry) => entry.destination.clone(),
543        None => return HttpResponse::bad_request("Destination not registered via API"),
544    };
545    drop(s);
546
547    with_node(node, |n| {
548        match n.send_packet(&dest, &data) {
549            Ok(ph) => HttpResponse::ok(json!({
550                "status": "sent",
551                "packet_hash": to_hex(&ph.0),
552            })),
553            Err(_) => HttpResponse::internal_error("Send failed"),
554        }
555    })
556}
557
558fn handle_post_link(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
559    let body = match parse_json_body(req) {
560        Ok(v) => v,
561        Err(r) => return r,
562    };
563
564    let dh_str = match body["dest_hash"].as_str() {
565        Some(s) => s,
566        None => return HttpResponse::bad_request("Missing dest_hash"),
567    };
568    let dh: [u8; 16] = match hex_to_array(dh_str) {
569        Some(h) => h,
570        None => return HttpResponse::bad_request("Invalid dest_hash"),
571    };
572
573    with_node(node, |n| {
574        // Recall identity to get signing public key
575        let recalled = match n.recall_identity(&DestHash(dh)) {
576            Ok(Some(ai)) => ai,
577            Ok(None) => return HttpResponse::bad_request("No recalled identity for dest_hash"),
578            Err(_) => return HttpResponse::internal_error("Query failed"),
579        };
580        // Extract Ed25519 public key (second 32 bytes of public_key)
581        let mut sig_pub = [0u8; 32];
582        sig_pub.copy_from_slice(&recalled.public_key[32..64]);
583
584        match n.create_link(dh, sig_pub) {
585            Ok(link_id) => HttpResponse::created(json!({
586                "link_id": to_hex(&link_id),
587            })),
588            Err(_) => HttpResponse::internal_error("Create link failed"),
589        }
590    })
591}
592
593fn handle_post_link_send(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
594    let body = match parse_json_body(req) {
595        Ok(v) => v,
596        Err(r) => return r,
597    };
598
599    let link_id: [u8; 16] = match body["link_id"]
600        .as_str()
601        .and_then(|s| hex_to_array(s))
602    {
603        Some(h) => h,
604        None => return HttpResponse::bad_request("Missing or invalid link_id"),
605    };
606    let data = match body["data"].as_str().and_then(from_base64) {
607        Some(d) => d,
608        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
609    };
610    let context = body["context"].as_u64().unwrap_or(0) as u8;
611
612    with_node(node, |n| {
613        match n.send_on_link(link_id, data, context) {
614            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
615            Err(_) => HttpResponse::internal_error("Send on link failed"),
616        }
617    })
618}
619
620fn handle_post_link_close(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
621    let body = match parse_json_body(req) {
622        Ok(v) => v,
623        Err(r) => return r,
624    };
625
626    let link_id: [u8; 16] = match body["link_id"]
627        .as_str()
628        .and_then(|s| hex_to_array(s))
629    {
630        Some(h) => h,
631        None => return HttpResponse::bad_request("Missing or invalid link_id"),
632    };
633
634    with_node(node, |n| {
635        match n.teardown_link(link_id) {
636            Ok(()) => HttpResponse::ok(json!({"status": "closed"})),
637            Err(_) => HttpResponse::internal_error("Teardown link failed"),
638        }
639    })
640}
641
642fn handle_post_channel(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
643    let body = match parse_json_body(req) {
644        Ok(v) => v,
645        Err(r) => return r,
646    };
647
648    let link_id: [u8; 16] = match body["link_id"]
649        .as_str()
650        .and_then(|s| hex_to_array(s))
651    {
652        Some(h) => h,
653        None => return HttpResponse::bad_request("Missing or invalid link_id"),
654    };
655    let msgtype = body["msgtype"].as_u64().unwrap_or(0) as u16;
656    let payload = match body["payload"].as_str().and_then(from_base64) {
657        Some(d) => d,
658        None => return HttpResponse::bad_request("Missing or invalid base64 payload"),
659    };
660
661    with_node(node, |n| {
662        match n.send_channel_message(link_id, msgtype, payload) {
663            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
664            Err(_) => HttpResponse::internal_error("Channel message failed"),
665        }
666    })
667}
668
669fn handle_post_resource(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
670    let body = match parse_json_body(req) {
671        Ok(v) => v,
672        Err(r) => return r,
673    };
674
675    let link_id: [u8; 16] = match body["link_id"]
676        .as_str()
677        .and_then(|s| hex_to_array(s))
678    {
679        Some(h) => h,
680        None => return HttpResponse::bad_request("Missing or invalid link_id"),
681    };
682    let data = match body["data"].as_str().and_then(from_base64) {
683        Some(d) => d,
684        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
685    };
686    let metadata = body["metadata"]
687        .as_str()
688        .and_then(from_base64);
689
690    with_node(node, |n| {
691        match n.send_resource(link_id, data, metadata) {
692            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
693            Err(_) => HttpResponse::internal_error("Resource send failed"),
694        }
695    })
696}
697
698fn handle_post_path_request(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
699    let body = match parse_json_body(req) {
700        Ok(v) => v,
701        Err(r) => return r,
702    };
703
704    let dh_str = match body["dest_hash"].as_str() {
705        Some(s) => s,
706        None => return HttpResponse::bad_request("Missing dest_hash"),
707    };
708    let dh: [u8; 16] = match hex_to_array(dh_str) {
709        Some(h) => h,
710        None => return HttpResponse::bad_request("Invalid dest_hash"),
711    };
712
713    with_node(node, |n| {
714        match n.request_path(&DestHash(dh)) {
715            Ok(()) => HttpResponse::ok(json!({"status": "requested"})),
716            Err(_) => HttpResponse::internal_error("Path request failed"),
717        }
718    })
719}
720
721// --- Helpers ---
722
723fn format_dest_name(app_name: &str, aspects: &[&str]) -> String {
724    if aspects.is_empty() {
725        app_name.to_string()
726    } else {
727        format!("{}.{}", app_name, aspects.join("."))
728    }
729}
730
731fn parse_proof_strategy(body: &Value) -> ProofStrategy {
732    match body["proof_strategy"].as_str() {
733        Some("all") => ProofStrategy::ProveAll,
734        Some("app") => ProofStrategy::ProveApp,
735        _ => ProofStrategy::ProveNone,
736    }
737}