Skip to main content

clawft_kernel/
http_facade.rs

1//! HTTP REST facade with SSE streaming for WeftOS.
2//!
3//! Closes Cognitum Seed gaps #6, #7, #8:
4//!
5//! - **Gap #6**: `GET /events` SSE delta stream from [`KernelEventLog`].
6//! - **Gap #7**: `POST /custody/witness` external chain-event injection.
7//! - **Gap #8**: REST API facade wrapping kernel Unix-socket RPC methods.
8//!
9//! # Architecture
10//!
11//! This module is **transport-agnostic**: it defines typed request/response
12//! structs, an SSE event formatter, and a dispatch layer that maps HTTP
13//! routes to kernel operations. The actual HTTP server binding (axum, hyper,
14//! etc.) is left to the binary crate or `clawft-services`.
15//!
16//! # Feature Gate
17//!
18//! Gated behind `http-api`. Not included in the default feature set.
19
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22
23use crate::console::{BootEvent, KernelEventLog, LogLevel};
24
25// ── SSE Event Types (Gap #6) ──────────────────────────────────
26
27/// Known SSE event types streamed on `GET /events`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum SseEventType {
31    /// Kernel initialized / boot complete.
32    Init,
33    /// Content ingested into vector store.
34    Ingest,
35    /// Content deleted from vector store.
36    Delete,
37    /// Vector store compaction completed.
38    Compact,
39    /// New event appended to the ExoChain.
40    ChainAppend,
41    /// Agent process spawned.
42    AgentSpawn,
43    /// Agent process stopped.
44    AgentStop,
45    /// Periodic heartbeat (keep-alive).
46    Heartbeat,
47}
48
49impl SseEventType {
50    /// SSE `event:` field value.
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            SseEventType::Init => "init",
54            SseEventType::Ingest => "ingest",
55            SseEventType::Delete => "delete",
56            SseEventType::Compact => "compact",
57            SseEventType::ChainAppend => "chain_append",
58            SseEventType::AgentSpawn => "agent_spawn",
59            SseEventType::AgentStop => "agent_stop",
60            SseEventType::Heartbeat => "heartbeat",
61        }
62    }
63}
64
65impl std::fmt::Display for SseEventType {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71/// A single SSE message ready for wire formatting.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SseMessage {
74    /// Event type for the SSE `event:` field.
75    pub event_type: SseEventType,
76    /// JSON payload for the SSE `data:` field.
77    pub data: serde_json::Value,
78    /// Wall-clock timestamp.
79    pub timestamp: String,
80}
81
82impl SseMessage {
83    /// Create a new SSE message.
84    pub fn new(event_type: SseEventType, data: serde_json::Value) -> Self {
85        Self {
86            event_type,
87            data,
88            timestamp: Utc::now().to_rfc3339(),
89        }
90    }
91
92    /// Format as an SSE wire frame: `event: {type}\ndata: {json}\n\n`.
93    pub fn to_sse_frame(&self) -> String {
94        let json = serde_json::to_string(&SsePayload {
95            data: &self.data,
96            timestamp: &self.timestamp,
97        })
98        .unwrap_or_else(|_| "{}".to_string());
99        format!("event: {}\ndata: {}\n\n", self.event_type.as_str(), json)
100    }
101
102    /// Create a heartbeat message (empty payload).
103    pub fn heartbeat() -> Self {
104        Self::new(SseEventType::Heartbeat, serde_json::json!({}))
105    }
106}
107
108/// Internal helper for SSE data serialization.
109#[derive(Serialize)]
110struct SsePayload<'a> {
111    data: &'a serde_json::Value,
112    timestamp: &'a str,
113}
114
115/// SSE heartbeat comment frame (`:heartbeat\n\n`).
116pub const SSE_HEARTBEAT_COMMENT: &str = ":heartbeat\n\n";
117
118/// Default heartbeat interval in seconds.
119pub const SSE_HEARTBEAT_INTERVAL_SECS: u64 = 15;
120
121/// Classify a kernel boot event into an SSE event type.
122///
123/// Maps event messages to the appropriate SSE event type based on
124/// content patterns. Returns `None` for events that should not be
125/// streamed (e.g., debug-level noise).
126pub fn classify_boot_event(event: &BootEvent) -> Option<SseEventType> {
127    let msg = &event.message;
128    if msg.contains("[agent]") && msg.contains("spawned") {
129        Some(SseEventType::AgentSpawn)
130    } else if msg.contains("[agent]") && msg.contains("stopped") {
131        Some(SseEventType::AgentStop)
132    } else if msg.contains("ingest") || msg.contains("ingested") {
133        Some(SseEventType::Ingest)
134    } else if msg.contains("delete") || msg.contains("deleted") {
135        Some(SseEventType::Delete)
136    } else if msg.contains("compact") {
137        Some(SseEventType::Compact)
138    } else if msg.contains("chain") && msg.contains("append") {
139        Some(SseEventType::ChainAppend)
140    } else if msg.contains("booting") || msg.contains("ready") {
141        Some(SseEventType::Init)
142    } else {
143        // Generic events still get streamed as init (catch-all).
144        // Callers can filter further.
145        None
146    }
147}
148
149/// Snapshot the event log and produce SSE messages for any new events
150/// since the given cursor position.
151///
152/// Returns `(messages, new_cursor)` where `new_cursor` is the total
153/// event count after this snapshot. Pass `new_cursor` as the cursor
154/// on the next poll to get only fresh events.
155pub fn poll_events(event_log: &KernelEventLog, cursor: usize) -> (Vec<SseMessage>, usize) {
156    let all = event_log.tail(0);
157    let total = all.len();
158    if cursor >= total {
159        return (Vec::new(), total);
160    }
161    let new_events = &all[cursor..];
162    let messages: Vec<SseMessage> = new_events
163        .iter()
164        .filter(|e| e.level != LogLevel::Debug)
165        .map(|e| {
166            let event_type = classify_boot_event(e).unwrap_or(SseEventType::Init);
167            SseMessage::new(
168                event_type,
169                serde_json::json!({
170                    "phase": e.phase.tag(),
171                    "level": format!("{:?}", e.level).to_lowercase(),
172                    "message": e.message,
173                }),
174            )
175        })
176        .collect();
177    (messages, total)
178}
179
180// ── External Witness Injection (Gap #7) ───────────────────────
181
182/// Request body for `POST /custody/witness`.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct WitnessRequest {
185    /// Chain event type (e.g., `"audit.external"`, `"attestation"`).
186    pub event_type: String,
187    /// Arbitrary JSON payload for the chain event.
188    pub payload: serde_json::Value,
189    /// Hex-encoded Ed25519 signature over `event_type || canonical(payload)`.
190    pub signature: String,
191}
192
193/// Response body for `POST /custody/witness`.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WitnessResponse {
196    /// Whether the witness event was accepted.
197    pub accepted: bool,
198    /// Hex-encoded hash of the appended chain event.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub chain_hash: Option<String>,
201    /// Sequence number of the appended event.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub sequence: Option<u64>,
204    /// Error message if rejected.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub error: Option<String>,
207}
208
209impl WitnessResponse {
210    /// Create a successful response.
211    pub fn accepted(hash_hex: String, sequence: u64) -> Self {
212        Self {
213            accepted: true,
214            chain_hash: Some(hash_hex),
215            sequence: Some(sequence),
216            error: None,
217        }
218    }
219
220    /// Create a rejection response.
221    pub fn rejected(reason: impl Into<String>) -> Self {
222        Self {
223            accepted: false,
224            chain_hash: None,
225            sequence: None,
226            error: Some(reason.into()),
227        }
228    }
229}
230
231/// Validate a hex-encoded Ed25519 signature.
232///
233/// Verifies `signature` over `event_type || canonical_json(payload)`
234/// against the provided verifying key bytes.
235///
236/// Returns `Ok(())` on valid signature, `Err(reason)` otherwise.
237#[cfg(feature = "exochain")]
238pub fn verify_witness_signature(
239    event_type: &str,
240    payload: &serde_json::Value,
241    signature_hex: &str,
242    verifying_key_bytes: &[u8; 32],
243) -> Result<(), String> {
244    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
245
246    let sig_bytes = hex_decode(signature_hex)
247        .map_err(|e| format!("invalid signature hex: {e}"))?;
248    if sig_bytes.len() != 64 {
249        return Err(format!(
250            "signature must be 64 bytes, got {}",
251            sig_bytes.len()
252        ));
253    }
254    let sig = Signature::from_bytes(
255        sig_bytes
256            .as_slice()
257            .try_into()
258            .map_err(|_| "signature length mismatch")?,
259    );
260    let vk = VerifyingKey::from_bytes(verifying_key_bytes)
261        .map_err(|e| format!("invalid verifying key: {e}"))?;
262
263    // Message = event_type bytes || canonical JSON bytes
264    let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
265    let mut message = Vec::with_capacity(event_type.len() + payload_bytes.len());
266    message.extend_from_slice(event_type.as_bytes());
267    message.extend_from_slice(&payload_bytes);
268
269    vk.verify(&message, &sig)
270        .map_err(|e| format!("signature verification failed: {e}"))
271}
272
273/// Decode a hex string to bytes.
274#[cfg(any(feature = "exochain", test))]
275fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
276    if !hex.len().is_multiple_of(2) {
277        return Err("odd-length hex string".into());
278    }
279    (0..hex.len())
280        .step_by(2)
281        .map(|i| {
282            u8::from_str_radix(&hex[i..i + 2], 16)
283                .map_err(|e| format!("invalid hex at offset {i}: {e}"))
284        })
285        .collect()
286}
287
288// ── REST API Facade (Gap #8) ──────────────────────────────────
289
290/// HTTP method for route matching.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum HttpMethod {
293    Get,
294    Post,
295    Delete,
296}
297
298impl HttpMethod {
299    /// Parse from an HTTP method string (case-insensitive).
300    pub fn parse(s: &str) -> Option<Self> {
301        match s.to_ascii_uppercase().as_str() {
302            "GET" => Some(HttpMethod::Get),
303            "POST" => Some(HttpMethod::Post),
304            "DELETE" => Some(HttpMethod::Delete),
305            _ => None,
306        }
307    }
308}
309
310/// A matched facade route with its target RPC method.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum FacadeRoute {
313    /// SSE event stream.
314    Events,
315    /// External witness injection.
316    CustodyWitness,
317    /// Mapped RPC call with method name and optional extracted params.
318    Rpc {
319        method: &'static str,
320        params: Option<RpcParam>,
321    },
322    /// No match.
323    NotFound,
324}
325
326/// Extracted route parameters (e.g., `:pid` from `/api/agents/:pid`).
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum RpcParam {
329    /// A PID extracted from the path.
330    Pid(u64),
331}
332
333/// Match an HTTP method + path to a facade route.
334///
335/// # Route Table
336///
337/// | HTTP | Path | RPC Method |
338/// |------|------|------------|
339/// | GET | `/events` | SSE stream |
340/// | POST | `/custody/witness` | witness injection |
341/// | GET | `/api/status` | `kernel.status` |
342/// | GET | `/api/processes` | `kernel.ps` |
343/// | GET | `/api/services` | `kernel.services` |
344/// | GET | `/api/chain/status` | `chain.status` |
345/// | GET | `/api/chain/events` | `kernel.logs` |
346/// | GET | `/api/vectors/status` | `ecc.status` |
347/// | POST | `/api/vectors/search` | `ecc.search` |
348/// | GET | `/api/ecc/calibration` | `ecc.calibrate` |
349/// | GET | `/api/custody/attest` | `custody.attest` |
350/// | GET | `/api/fleet/snapshot` | `fleet.snapshot` |
351/// | POST | `/api/agents/spawn` | `agent.spawn` |
352/// | DELETE | `/api/agents/:pid` | `agent.stop` |
353pub fn match_facade_route(method: HttpMethod, path: &str) -> FacadeRoute {
354    // Strip query string for matching.
355    let path = path.split('?').next().unwrap_or(path);
356    // Normalize trailing slash.
357    let path = path.trim_end_matches('/');
358
359    match (method, path) {
360        // Gap #6: SSE stream
361        (HttpMethod::Get, "/events") => FacadeRoute::Events,
362
363        // Gap #7: Witness injection
364        (HttpMethod::Post, "/custody/witness") => FacadeRoute::CustodyWitness,
365
366        // Gap #8: REST facade
367        (HttpMethod::Get, "/api/status") => FacadeRoute::Rpc {
368            method: "kernel.status",
369            params: None,
370        },
371        (HttpMethod::Get, "/api/processes") => FacadeRoute::Rpc {
372            method: "kernel.ps",
373            params: None,
374        },
375        (HttpMethod::Get, "/api/services") => FacadeRoute::Rpc {
376            method: "kernel.services",
377            params: None,
378        },
379        (HttpMethod::Get, "/api/chain/status") => FacadeRoute::Rpc {
380            method: "chain.status",
381            params: None,
382        },
383        (HttpMethod::Get, "/api/chain/events") => FacadeRoute::Rpc {
384            method: "kernel.logs",
385            params: None,
386        },
387        (HttpMethod::Get, "/api/vectors/status") => FacadeRoute::Rpc {
388            method: "ecc.status",
389            params: None,
390        },
391        (HttpMethod::Post, "/api/vectors/search") => FacadeRoute::Rpc {
392            method: "ecc.search",
393            params: None,
394        },
395        (HttpMethod::Get, "/api/ecc/calibration") => FacadeRoute::Rpc {
396            method: "ecc.calibrate",
397            params: None,
398        },
399        (HttpMethod::Get, "/api/ecc/coherence") => FacadeRoute::Rpc {
400            method: "ecc.coherence",
401            params: None,
402        },
403        (HttpMethod::Get, "/api/custody/attest") => FacadeRoute::Rpc {
404            method: "custody.attest",
405            params: None,
406        },
407        // Fleet manager: one read-only snapshot of everything the daemon knows
408        // about the fleet (card fleet-p1).
409        (HttpMethod::Get, "/api/fleet/snapshot") => FacadeRoute::Rpc {
410            method: "fleet.snapshot",
411            params: None,
412        },
413        (HttpMethod::Post, "/api/agents/spawn") => FacadeRoute::Rpc {
414            method: "agent.spawn",
415            params: None,
416        },
417        // DELETE /api/agents/:pid
418        (HttpMethod::Delete, p) if p.starts_with("/api/agents/") => {
419            let pid_str = &p["/api/agents/".len()..];
420            match pid_str.parse::<u64>() {
421                Ok(pid) => FacadeRoute::Rpc {
422                    method: "agent.stop",
423                    params: Some(RpcParam::Pid(pid)),
424                },
425                Err(_) => FacadeRoute::NotFound,
426            }
427        }
428
429        _ => FacadeRoute::NotFound,
430    }
431}
432
433/// Build RPC params JSON from the HTTP request body and route params.
434///
435/// For `GET` requests with query strings, extracts `count` parameter.
436/// For `DELETE /api/agents/:pid`, injects the PID into stop params.
437/// For `POST` endpoints, passes the body through.
438pub fn build_rpc_params(
439    route: &FacadeRoute,
440    body: &[u8],
441    query: &str,
442) -> serde_json::Value {
443    match route {
444        FacadeRoute::Rpc {
445            method: "kernel.logs",
446            ..
447        } => {
448            // Extract ?count=N from query string
449            let count = parse_query_param(query, "count")
450                .and_then(|v| v.parse::<usize>().ok())
451                .unwrap_or(50);
452            serde_json::json!({ "count": count })
453        }
454        FacadeRoute::Rpc {
455            method: "agent.stop",
456            params: Some(RpcParam::Pid(pid)),
457        } => {
458            serde_json::json!({ "pid": pid, "graceful": true })
459        }
460        FacadeRoute::Rpc { .. } => {
461            // Try parsing body as JSON, fall back to empty object
462            serde_json::from_slice(body).unwrap_or(serde_json::json!({}))
463        }
464        _ => serde_json::json!({}),
465    }
466}
467
468/// Parse a single query-string parameter by name.
469fn parse_query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> {
470    query
471        .split('&')
472        .find_map(|pair| {
473            let (k, v) = pair.split_once('=')?;
474            if k == key { Some(v) } else { None }
475        })
476}
477
478// ── Facade dispatch result ──────────────────────────────────────
479
480/// HTTP status code + JSON body returned by the facade.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct FacadeResponse {
483    /// HTTP status code (200, 400, 404, 500, etc.).
484    pub status: u16,
485    /// JSON response body.
486    pub body: serde_json::Value,
487}
488
489impl FacadeResponse {
490    /// 200 OK with JSON body.
491    pub fn ok(body: serde_json::Value) -> Self {
492        Self { status: 200, body }
493    }
494
495    /// 400 Bad Request.
496    pub fn bad_request(message: impl Into<String>) -> Self {
497        Self {
498            status: 400,
499            body: serde_json::json!({ "error": message.into() }),
500        }
501    }
502
503    /// 404 Not Found.
504    pub fn not_found() -> Self {
505        Self {
506            status: 404,
507            body: serde_json::json!({ "error": "not found" }),
508        }
509    }
510
511    /// 500 Internal Server Error.
512    pub fn internal_error(message: impl Into<String>) -> Self {
513        Self {
514            status: 500,
515            body: serde_json::json!({ "error": message.into() }),
516        }
517    }
518
519    /// 401 Unauthorized.
520    pub fn unauthorized(message: impl Into<String>) -> Self {
521        Self {
522            status: 401,
523            body: serde_json::json!({ "error": message.into() }),
524        }
525    }
526}
527
528// ── Content-Type headers ────────────────────────────────────────
529
530/// Content-Type for SSE responses.
531pub const CONTENT_TYPE_SSE: &str = "text/event-stream";
532
533/// Content-Type for JSON responses.
534pub const CONTENT_TYPE_JSON: &str = "application/json";
535
536/// Required SSE response headers as `(name, value)` pairs.
537pub fn sse_response_headers() -> Vec<(&'static str, &'static str)> {
538    vec![
539        ("Content-Type", CONTENT_TYPE_SSE),
540        ("Cache-Control", "no-cache"),
541        ("Connection", "keep-alive"),
542        ("X-Accel-Buffering", "no"),
543    ]
544}
545
546// ── Tests ───────────────────────────────────────────────────────
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::console::{BootEvent, BootPhase, KernelEventLog};
552
553    // ── SSE formatting tests ────────────────────────────────
554
555    #[test]
556    fn sse_message_format() {
557        let msg = SseMessage::new(
558            SseEventType::AgentSpawn,
559            serde_json::json!({"pid": 42, "agent_id": "coder-1"}),
560        );
561        let frame = msg.to_sse_frame();
562        assert!(frame.starts_with("event: agent_spawn\n"));
563        assert!(frame.contains("data: "));
564        assert!(frame.ends_with("\n\n"));
565        // Data should be valid JSON
566        let data_line = frame
567            .lines()
568            .find(|l| l.starts_with("data: "))
569            .unwrap();
570        let json_str = &data_line["data: ".len()..];
571        let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
572        assert!(parsed.get("data").is_some());
573        assert!(parsed.get("timestamp").is_some());
574    }
575
576    #[test]
577    fn sse_heartbeat_comment() {
578        assert_eq!(SSE_HEARTBEAT_COMMENT, ":heartbeat\n\n");
579    }
580
581    #[test]
582    fn sse_heartbeat_message() {
583        let msg = SseMessage::heartbeat();
584        assert_eq!(msg.event_type, SseEventType::Heartbeat);
585        let frame = msg.to_sse_frame();
586        assert!(frame.starts_with("event: heartbeat\n"));
587    }
588
589    #[test]
590    fn sse_event_type_display() {
591        assert_eq!(SseEventType::Init.as_str(), "init");
592        assert_eq!(SseEventType::ChainAppend.as_str(), "chain_append");
593        assert_eq!(SseEventType::AgentSpawn.as_str(), "agent_spawn");
594        assert_eq!(SseEventType::Heartbeat.as_str(), "heartbeat");
595    }
596
597    // ── Event classification tests ──────────────────────────
598
599    #[test]
600    fn classify_agent_spawn_event() {
601        let event = BootEvent::info(BootPhase::Ready, "[agent] spawned coder-1 (PID 5)");
602        assert_eq!(classify_boot_event(&event), Some(SseEventType::AgentSpawn));
603    }
604
605    #[test]
606    fn classify_agent_stop_event() {
607        let event = BootEvent::info(BootPhase::Ready, "[agent] stopped PID 5");
608        assert_eq!(classify_boot_event(&event), Some(SseEventType::AgentStop));
609    }
610
611    #[test]
612    fn classify_ingest_event() {
613        let event = BootEvent::info(BootPhase::Ready, "content ingested into store");
614        assert_eq!(classify_boot_event(&event), Some(SseEventType::Ingest));
615    }
616
617    #[test]
618    fn classify_compact_event() {
619        let event = BootEvent::info(BootPhase::Ready, "vector store compaction complete");
620        assert_eq!(classify_boot_event(&event), Some(SseEventType::Compact));
621    }
622
623    #[test]
624    fn classify_init_event() {
625        let event = BootEvent::info(BootPhase::Init, "WeftOS booting...");
626        assert_eq!(classify_boot_event(&event), Some(SseEventType::Init));
627    }
628
629    #[test]
630    fn classify_unknown_event() {
631        let event = BootEvent::info(BootPhase::Ready, "some random message");
632        assert_eq!(classify_boot_event(&event), None);
633    }
634
635    // ── Event polling tests ─────────────────────────────────
636
637    #[test]
638    fn poll_events_empty_log() {
639        let log = KernelEventLog::new();
640        let (messages, cursor) = poll_events(&log, 0);
641        assert!(messages.is_empty());
642        assert_eq!(cursor, 0);
643    }
644
645    #[test]
646    fn poll_events_with_cursor() {
647        let log = KernelEventLog::new();
648        log.info("agent", "spawned coder-1 (PID 5)");
649        log.info("test", "second event");
650
651        // First poll: get both
652        let (msgs, cursor) = poll_events(&log, 0);
653        assert_eq!(msgs.len(), 2);
654        assert_eq!(cursor, 2);
655
656        // Second poll with updated cursor: nothing new
657        let (msgs, cursor2) = poll_events(&log, cursor);
658        assert!(msgs.is_empty());
659        assert_eq!(cursor2, 2);
660
661        // Add one more event
662        log.info("agent", "stopped PID 5");
663        let (msgs, cursor3) = poll_events(&log, cursor2);
664        assert_eq!(msgs.len(), 1);
665        assert_eq!(cursor3, 3);
666    }
667
668    // ── Witness types tests ─────────────────────────────────
669
670    #[test]
671    fn witness_response_accepted() {
672        let resp = WitnessResponse::accepted("abcd1234".into(), 42);
673        assert!(resp.accepted);
674        assert_eq!(resp.chain_hash.as_deref(), Some("abcd1234"));
675        assert_eq!(resp.sequence, Some(42));
676        assert!(resp.error.is_none());
677    }
678
679    #[test]
680    fn witness_response_rejected() {
681        let resp = WitnessResponse::rejected("bad signature");
682        assert!(!resp.accepted);
683        assert!(resp.chain_hash.is_none());
684        assert_eq!(resp.error.as_deref(), Some("bad signature"));
685    }
686
687    #[test]
688    fn witness_request_serde() {
689        let req = WitnessRequest {
690            event_type: "audit.external".into(),
691            payload: serde_json::json!({"source": "client-A"}),
692            signature: "deadbeef".into(),
693        };
694        let json = serde_json::to_string(&req).unwrap();
695        let restored: WitnessRequest = serde_json::from_str(&json).unwrap();
696        assert_eq!(restored.event_type, "audit.external");
697        assert_eq!(restored.signature, "deadbeef");
698    }
699
700    // ── Hex decode tests ────────────────────────────────────
701
702    #[test]
703    fn hex_decode_valid() {
704        let bytes = hex_decode("deadbeef").unwrap();
705        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
706    }
707
708    #[test]
709    fn hex_decode_empty() {
710        let bytes = hex_decode("").unwrap();
711        assert!(bytes.is_empty());
712    }
713
714    #[test]
715    fn hex_decode_odd_length() {
716        assert!(hex_decode("abc").is_err());
717    }
718
719    #[test]
720    fn hex_decode_invalid_chars() {
721        assert!(hex_decode("zzzz").is_err());
722    }
723
724    // ── Route matching tests ────────────────────────────────
725
726    #[test]
727    fn route_events() {
728        assert_eq!(
729            match_facade_route(HttpMethod::Get, "/events"),
730            FacadeRoute::Events
731        );
732    }
733
734    #[test]
735    fn route_events_with_trailing_slash() {
736        assert_eq!(
737            match_facade_route(HttpMethod::Get, "/events/"),
738            FacadeRoute::Events
739        );
740    }
741
742    #[test]
743    fn route_custody_witness() {
744        assert_eq!(
745            match_facade_route(HttpMethod::Post, "/custody/witness"),
746            FacadeRoute::CustodyWitness
747        );
748    }
749
750    #[test]
751    fn route_api_status() {
752        let route = match_facade_route(HttpMethod::Get, "/api/status");
753        assert_eq!(
754            route,
755            FacadeRoute::Rpc {
756                method: "kernel.status",
757                params: None
758            }
759        );
760    }
761
762    #[test]
763    fn route_api_processes() {
764        let route = match_facade_route(HttpMethod::Get, "/api/processes");
765        assert_eq!(
766            route,
767            FacadeRoute::Rpc {
768                method: "kernel.ps",
769                params: None
770            }
771        );
772    }
773
774    #[test]
775    fn route_api_fleet_snapshot_is_get_only() {
776        assert_eq!(
777            match_facade_route(HttpMethod::Get, "/api/fleet/snapshot"),
778            FacadeRoute::Rpc {
779                method: "fleet.snapshot",
780                params: None
781            }
782        );
783        // A write verb is never reachable through the facade route table.
784        assert_eq!(
785            match_facade_route(HttpMethod::Post, "/api/fleet/snapshot"),
786            FacadeRoute::NotFound
787        );
788        assert_eq!(
789            match_facade_route(HttpMethod::Post, "/api/fleet/location"),
790            FacadeRoute::NotFound
791        );
792    }
793
794    #[test]
795    fn route_api_chain_status() {
796        let route = match_facade_route(HttpMethod::Get, "/api/chain/status");
797        assert_eq!(
798            route,
799            FacadeRoute::Rpc {
800                method: "chain.status",
801                params: None
802            }
803        );
804    }
805
806    #[test]
807    fn route_api_chain_events() {
808        let route = match_facade_route(HttpMethod::Get, "/api/chain/events");
809        assert_eq!(
810            route,
811            FacadeRoute::Rpc {
812                method: "kernel.logs",
813                params: None
814            }
815        );
816    }
817
818    #[test]
819    fn route_api_chain_events_with_query() {
820        let route = match_facade_route(HttpMethod::Get, "/api/chain/events?count=20");
821        assert_eq!(
822            route,
823            FacadeRoute::Rpc {
824                method: "kernel.logs",
825                params: None
826            }
827        );
828    }
829
830    #[test]
831    fn route_api_vectors_status() {
832        let route = match_facade_route(HttpMethod::Get, "/api/vectors/status");
833        assert_eq!(
834            route,
835            FacadeRoute::Rpc {
836                method: "ecc.status",
837                params: None
838            }
839        );
840    }
841
842    #[test]
843    fn route_api_vectors_search() {
844        let route = match_facade_route(HttpMethod::Post, "/api/vectors/search");
845        assert_eq!(
846            route,
847            FacadeRoute::Rpc {
848                method: "ecc.search",
849                params: None
850            }
851        );
852    }
853
854    #[test]
855    fn route_api_agents_spawn() {
856        let route = match_facade_route(HttpMethod::Post, "/api/agents/spawn");
857        assert_eq!(
858            route,
859            FacadeRoute::Rpc {
860                method: "agent.spawn",
861                params: None
862            }
863        );
864    }
865
866    #[test]
867    fn route_api_agents_delete() {
868        let route = match_facade_route(HttpMethod::Delete, "/api/agents/42");
869        assert_eq!(
870            route,
871            FacadeRoute::Rpc {
872                method: "agent.stop",
873                params: Some(RpcParam::Pid(42))
874            }
875        );
876    }
877
878    #[test]
879    fn route_api_agents_delete_invalid_pid() {
880        let route = match_facade_route(HttpMethod::Delete, "/api/agents/notanumber");
881        assert_eq!(route, FacadeRoute::NotFound);
882    }
883
884    #[test]
885    fn route_not_found() {
886        assert_eq!(
887            match_facade_route(HttpMethod::Get, "/nonexistent"),
888            FacadeRoute::NotFound
889        );
890    }
891
892    #[test]
893    fn route_wrong_method() {
894        // POST to a GET-only route
895        assert_eq!(
896            match_facade_route(HttpMethod::Post, "/api/status"),
897            FacadeRoute::NotFound
898        );
899    }
900
901    #[test]
902    fn route_ecc_calibration() {
903        let route = match_facade_route(HttpMethod::Get, "/api/ecc/calibration");
904        assert_eq!(
905            route,
906            FacadeRoute::Rpc {
907                method: "ecc.calibrate",
908                params: None
909            }
910        );
911    }
912
913    #[test]
914    fn route_ecc_coherence() {
915        let route = match_facade_route(HttpMethod::Get, "/api/ecc/coherence");
916        assert_eq!(
917            route,
918            FacadeRoute::Rpc {
919                method: "ecc.coherence",
920                params: None
921            }
922        );
923    }
924
925    #[test]
926    fn route_custody_attest() {
927        let route = match_facade_route(HttpMethod::Get, "/api/custody/attest");
928        assert_eq!(
929            route,
930            FacadeRoute::Rpc {
931                method: "custody.attest",
932                params: None
933            }
934        );
935    }
936
937    // ── RPC params building tests ───────────────────────────
938
939    #[test]
940    fn build_params_kernel_logs_with_count() {
941        let route = FacadeRoute::Rpc {
942            method: "kernel.logs",
943            params: None,
944        };
945        let params = build_rpc_params(&route, b"", "count=25");
946        assert_eq!(params["count"], 25);
947    }
948
949    #[test]
950    fn build_params_kernel_logs_default_count() {
951        let route = FacadeRoute::Rpc {
952            method: "kernel.logs",
953            params: None,
954        };
955        let params = build_rpc_params(&route, b"", "");
956        assert_eq!(params["count"], 50);
957    }
958
959    #[test]
960    fn build_params_agent_stop() {
961        let route = FacadeRoute::Rpc {
962            method: "agent.stop",
963            params: Some(RpcParam::Pid(99)),
964        };
965        let params = build_rpc_params(&route, b"", "");
966        assert_eq!(params["pid"], 99);
967        assert_eq!(params["graceful"], true);
968    }
969
970    #[test]
971    fn build_params_from_body() {
972        let route = FacadeRoute::Rpc {
973            method: "agent.spawn",
974            params: None,
975        };
976        let body = br#"{"agent_id":"coder-1"}"#;
977        let params = build_rpc_params(&route, body, "");
978        assert_eq!(params["agent_id"], "coder-1");
979    }
980
981    #[test]
982    fn build_params_invalid_body_fallback() {
983        let route = FacadeRoute::Rpc {
984            method: "agent.spawn",
985            params: None,
986        };
987        let params = build_rpc_params(&route, b"not json", "");
988        assert_eq!(params, serde_json::json!({}));
989    }
990
991    // ── Query param parsing tests ───────────────────────────
992
993    #[test]
994    fn parse_query_param_found() {
995        assert_eq!(parse_query_param("count=20&level=info", "count"), Some("20"));
996        assert_eq!(parse_query_param("count=20&level=info", "level"), Some("info"));
997    }
998
999    #[test]
1000    fn parse_query_param_not_found() {
1001        assert_eq!(parse_query_param("count=20", "level"), None);
1002    }
1003
1004    #[test]
1005    fn parse_query_param_empty() {
1006        assert_eq!(parse_query_param("", "count"), None);
1007    }
1008
1009    // ── HttpMethod parse tests ──────────────────────────────
1010
1011    #[test]
1012    fn http_method_parse() {
1013        assert_eq!(HttpMethod::parse("GET"), Some(HttpMethod::Get));
1014        assert_eq!(HttpMethod::parse("get"), Some(HttpMethod::Get));
1015        assert_eq!(HttpMethod::parse("POST"), Some(HttpMethod::Post));
1016        assert_eq!(HttpMethod::parse("DELETE"), Some(HttpMethod::Delete));
1017        assert_eq!(HttpMethod::parse("PATCH"), None);
1018    }
1019
1020    // ── FacadeResponse tests ────────────────────────────────
1021
1022    #[test]
1023    fn facade_response_ok() {
1024        let resp = FacadeResponse::ok(serde_json::json!({"status": "running"}));
1025        assert_eq!(resp.status, 200);
1026    }
1027
1028    #[test]
1029    fn facade_response_not_found() {
1030        let resp = FacadeResponse::not_found();
1031        assert_eq!(resp.status, 404);
1032    }
1033
1034    #[test]
1035    fn facade_response_bad_request() {
1036        let resp = FacadeResponse::bad_request("missing field");
1037        assert_eq!(resp.status, 400);
1038        assert_eq!(resp.body["error"], "missing field");
1039    }
1040
1041    #[test]
1042    fn facade_response_unauthorized() {
1043        let resp = FacadeResponse::unauthorized("invalid token");
1044        assert_eq!(resp.status, 401);
1045    }
1046
1047    // ── SSE headers test ────────────────────────────────────
1048
1049    #[test]
1050    fn sse_headers() {
1051        let headers = sse_response_headers();
1052        assert!(headers.iter().any(|(k, v)| *k == "Content-Type" && *v == "text/event-stream"));
1053        assert!(headers.iter().any(|(k, _)| *k == "Cache-Control"));
1054    }
1055
1056    // ── Content type constants ──────────────────────────────
1057
1058    #[test]
1059    fn content_types() {
1060        assert_eq!(CONTENT_TYPE_SSE, "text/event-stream");
1061        assert_eq!(CONTENT_TYPE_JSON, "application/json");
1062    }
1063}