Skip to main content

koi_embedded/
lib.rs

1mod config;
2mod events;
3mod handle;
4mod serve;
5pub mod testkit;
6
7use std::sync::Arc;
8
9use tokio::sync::broadcast;
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12
13use koi_client::KoiClient;
14
15pub use config::{DnsConfigBuilder, KoiConfig, ServiceMode};
16pub use events::KoiEvent;
17pub use handle::{
18    CertmeshHandle, DnsHandle, HealthHandle, KoiHandle, MdnsHandle, ProxyHandle,
19    DEFAULT_DISCOVER_WINDOW,
20};
21
22// Re-export types needed by downstream consumers (registration, discovery, DNS, proxy, health)
23pub use koi_common::firewall::{FirewallPort, FirewallProtocol};
24// Mode-transparent trust primitives (ADR-020): typed discovery + posture + posture-keyed client.
25pub use koi_certmesh::PeerClient;
26pub use koi_common::diagnosis::{CheckStatus, DiagnosisCheck, DiagnosisStatus, TrustDiagnosis};
27pub use koi_common::peer::Peer;
28pub use koi_common::posture::{Posture, PostureLevel};
29pub use koi_common::sealed::{Confidentiality, Opened, Sealed};
30pub use koi_common::types::ServiceRecord;
31pub use koi_config::state::DnsEntry;
32pub use koi_health::{HealthCheck, HealthSnapshot, ServiceCheckKind};
33pub use koi_mdns::protocol::{RegisterPayload, RegistrationResult};
34pub use koi_mdns::MdnsEvent;
35pub use koi_proxy::ProxyEntry;
36// Same-port posture dial (ADR-020 §5): plain↔mTLS on one socket, live-flipping.
37pub use serve::serve_adaptive;
38
39// Vault: general-purpose encrypted secret storage
40pub use koi_crypto::vault::{Vault, VaultError};
41
42// Runtime adapter re-exports
43pub use koi_runtime::{RuntimeBackendKind, RuntimeConfig};
44
45pub type Result<T> = std::result::Result<T, KoiError>;
46
47#[derive(Debug, thiserror::Error)]
48pub enum KoiError {
49    #[error("capability disabled: {0}")]
50    DisabledCapability(&'static str),
51    #[error("not available in client (remote) mode: {0}")]
52    RemoteUnsupported(&'static str),
53    #[error("mdns error: {0}")]
54    Mdns(#[from] koi_mdns::MdnsError),
55    #[error("dns error: {0}")]
56    Dns(#[from] koi_dns::DnsError),
57    #[error("health error: {0}")]
58    Health(#[from] koi_health::HealthError),
59    #[error("proxy error: {0}")]
60    Proxy(#[from] koi_proxy::ProxyError),
61    #[error("certmesh error: {0}")]
62    Certmesh(#[from] koi_certmesh::CertmeshError),
63    #[error("runtime error: {0}")]
64    Runtime(#[from] koi_runtime::RuntimeError),
65    #[error("client error: {0}")]
66    Client(#[from] koi_client::ClientError),
67    #[error("io error: {0}")]
68    Io(#[from] std::io::Error),
69    #[error("insecure configuration: {0}")]
70    InsecureConfig(String),
71}
72
73impl From<koi_compose::cores::BuildCoresError> for KoiError {
74    fn from(e: koi_compose::cores::BuildCoresError) -> Self {
75        use koi_compose::cores::BuildCoresError as B;
76        match e {
77            B::Mdns(e) => KoiError::Mdns(e),
78            B::Dns(e) => KoiError::Dns(e),
79            B::Proxy(e) => KoiError::Proxy(e),
80            B::Health(e) => KoiError::Health(e),
81            B::CertmeshInit(s) => KoiError::Io(std::io::Error::other(s)),
82        }
83    }
84}
85
86pub struct Builder {
87    config: KoiConfig,
88    event_handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
89    extra_firewall_ports: Vec<koi_common::firewall::FirewallPort>,
90}
91
92impl Builder {
93    pub fn new() -> Self {
94        Self {
95            config: KoiConfig::default(),
96            event_handler: None,
97            extra_firewall_ports: Vec::new(),
98        }
99    }
100
101    pub fn data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
102        self.config.data_dir = Some(path.into());
103        self
104    }
105
106    pub fn service_endpoint(mut self, endpoint: impl Into<String>) -> Self {
107        self.config.service_endpoint = endpoint.into();
108        self
109    }
110
111    pub fn service_mode(mut self, mode: ServiceMode) -> Self {
112        self.config.service_mode = mode;
113        self
114    }
115
116    pub fn http(mut self, enabled: bool) -> Self {
117        self.config.http_enabled = enabled;
118        self
119    }
120
121    pub fn mdns(mut self, enabled: bool) -> Self {
122        self.config.mdns_enabled = enabled;
123        self
124    }
125
126    pub fn dns<F>(mut self, configure: F) -> Self
127    where
128        F: FnOnce(DnsConfigBuilder) -> DnsConfigBuilder,
129    {
130        let builder = DnsConfigBuilder::new(self.config.dns_config.clone());
131        self.config.dns_config = configure(builder).build();
132        self
133    }
134
135    pub fn dns_enabled(mut self, enabled: bool) -> Self {
136        self.config.dns_enabled = enabled;
137        self
138    }
139
140    pub fn dns_auto_start(mut self, enabled: bool) -> Self {
141        self.config.dns_auto_start = enabled;
142        self
143    }
144
145    pub fn health(mut self, enabled: bool) -> Self {
146        self.config.health_enabled = enabled;
147        self
148    }
149
150    pub fn health_auto_start(mut self, enabled: bool) -> Self {
151        self.config.health_auto_start = enabled;
152        self
153    }
154
155    pub fn certmesh(mut self, enabled: bool) -> Self {
156        self.config.certmesh_enabled = enabled;
157        self
158    }
159
160    pub fn proxy(mut self, enabled: bool) -> Self {
161        self.config.proxy_enabled = enabled;
162        self
163    }
164
165    pub fn proxy_auto_start(mut self, enabled: bool) -> Self {
166        self.config.proxy_auto_start = enabled;
167        self
168    }
169
170    pub fn udp(mut self, enabled: bool) -> Self {
171        self.config.udp_enabled = enabled;
172        self
173    }
174
175    /// Enable the runtime adapter with the specified backend kind.
176    ///
177    /// Runtime is opt-in for embedded (unlike daemon where capabilities
178    /// are enabled by default).
179    pub fn runtime(mut self, kind: koi_runtime::RuntimeBackendKind) -> Self {
180        self.config.runtime_enabled = true;
181        self.config.runtime_backend = kind;
182        self
183    }
184
185    /// Enable the runtime adapter with auto-detection.
186    pub fn runtime_auto(mut self) -> Self {
187        self.config.runtime_enabled = true;
188        self.config.runtime_backend = koi_runtime::RuntimeBackendKind::Auto;
189        self
190    }
191
192    /// Translate discovered runtime (container) lifecycle events into mDNS/DNS/health/proxy
193    /// entries — the same orchestrator the daemon runs. Opt-in; requires the runtime
194    /// adapter (`runtime`/`runtime_auto`) to be enabled to have any effect.
195    pub fn orchestrator(mut self, enabled: bool) -> Self {
196        self.config.orchestrator_enabled = enabled;
197        self
198    }
199
200    /// Run the certmesh role-driven background loop (trust-bundle pull — policy refresh +
201    /// revocation detection — plus cert renewal) — the same loop the daemon runs. Opt-in;
202    /// requires certmesh (`certmesh`) to be enabled. A clustered embedded CA host wants
203    /// this; a leaf does not. Enrollment approval auto-denies (no interactive console).
204    pub fn certmesh_background(mut self, enabled: bool) -> Self {
205        self.config.certmesh_background_enabled = enabled;
206        self
207    }
208
209    /// Set the embedded HTTP adapter's port. Pass `0` to bind an OS-assigned
210    /// ephemeral port and read the actual one back with
211    /// [`KoiHandle::bound_http_port`] after [`start`](KoiEmbedded::start) — the
212    /// supported way to run on a free port without racing to pick one.
213    pub fn http_port(mut self, port: u16) -> Self {
214        self.config.http_port = port;
215        self
216    }
217
218    pub fn dashboard(mut self, enabled: bool) -> Self {
219        self.config.dashboard_enabled = enabled;
220        self
221    }
222
223    pub fn api_docs(mut self, enabled: bool) -> Self {
224        self.config.api_docs_enabled = enabled;
225        self
226    }
227
228    pub fn mdns_browser(mut self, enabled: bool) -> Self {
229        self.config.mdns_browser_enabled = enabled;
230        self
231    }
232
233    /// Advertise this host's `_http._tcp` record on the LAN so peers discover it. Because
234    /// the advertised endpoint must be reachable, enabling this binds the embedded HTTP
235    /// adapter to `0.0.0.0` (all interfaces) instead of the secure loopback default.
236    /// **Strongly pair with [`http_token`](Self::http_token)** — otherwise mutations are
237    /// unauthenticated to the whole LAN (a runtime warning is logged if you do not).
238    pub fn announce_http(mut self, enabled: bool) -> Self {
239        self.config.announce_http = enabled;
240        self
241    }
242
243    /// Require an `x-koi-token` header for embedded HTTP mutations (parity with the
244    /// daemon's DAT). Optional: by default the embedded HTTP adapter binds loopback and
245    /// leaves mutations unauthenticated. Set a token when exposing the adapter to the LAN
246    /// (see [`announce_http`](Self::announce_http)).
247    pub fn http_token(mut self, token: impl Into<String>) -> Self {
248        self.config.http_token = Some(token.into());
249        self
250    }
251
252    pub fn events<F>(mut self, handler: F) -> Self
253    where
254        F: Fn(KoiEvent) + Send + Sync + 'static,
255    {
256        self.event_handler = Some(Arc::new(handler));
257        self
258    }
259
260    /// Register additional firewall ports that the host application needs
261    /// opened (e.g. an application's discovery UDP, HTTP API).  These are merged with
262    /// the ports from enabled Koi capabilities when `ensure_firewall_rules`
263    /// is called.
264    pub fn extra_firewall_ports(mut self, ports: Vec<koi_common::firewall::FirewallPort>) -> Self {
265        self.extra_firewall_ports = ports;
266        self
267    }
268
269    /// Best-effort ensure that Windows Firewall inbound-allow rules exist
270    /// for every port required by the enabled capabilities **plus** any
271    /// extra ports registered by the host application.
272    ///
273    /// * Idempotent — safe to call on every startup.
274    /// * Non-fatal  — logs warnings but never fails the build.
275    /// * No-op on non-Windows platforms.
276    ///
277    /// `prefix` is used in the firewall rule display-names
278    /// (e.g. `"My App"` → `"My App mDNS (UDP 5353)"`).
279    pub fn ensure_firewall_rules(self, prefix: &str) -> Self {
280        let mut all_ports = self.config.firewall_ports();
281        all_ports.extend(self.extra_firewall_ports.iter().cloned());
282
283        let count = koi_common::firewall::ensure_firewall_rules(prefix, &all_ports);
284        if count > 0 {
285            tracing::info!(count, "Firewall rules ensured");
286        }
287        self
288    }
289
290    pub fn build(self) -> Result<KoiEmbedded> {
291        Ok(KoiEmbedded {
292            config: self.config,
293            event_handler: self.event_handler,
294        })
295    }
296}
297
298impl Default for Builder {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304pub struct KoiEmbedded {
305    config: KoiConfig,
306    event_handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
307}
308
309impl KoiEmbedded {
310    pub async fn start(self) -> Result<KoiHandle> {
311        let cancel = CancellationToken::new();
312        let (event_tx, _) = broadcast::channel(256);
313        let mut tasks: Vec<JoinHandle<()>> = Vec::new();
314
315        if self.config.service_mode != ServiceMode::EmbeddedOnly {
316            let client = Arc::new(KoiClient::new(&self.config.service_endpoint));
317            match self.config.service_mode {
318                ServiceMode::ClientOnly => {
319                    tokio::task::spawn_blocking({
320                        let client = Arc::clone(&client);
321                        move || client.health()
322                    })
323                    .await
324                    .map_err(map_join_error)??;
325                    return Ok(KoiHandle::new_remote(client, event_tx, cancel, tasks));
326                }
327                ServiceMode::Auto => {
328                    let health = tokio::task::spawn_blocking({
329                        let client = Arc::clone(&client);
330                        move || client.health()
331                    })
332                    .await;
333                    if matches!(health, Ok(Ok(()))) {
334                        return Ok(KoiHandle::new_remote(client, event_tx, cancel, tasks));
335                    }
336                }
337                ServiceMode::EmbeddedOnly => {}
338            }
339        }
340
341        // Secure-by-default: refuse to expose the embedded HTTP adapter on a
342        // non-loopback bind without a token. `announce_http` binds 0.0.0.0, which
343        // would otherwise serve unauthenticated mutations to the whole LAN — the
344        // host must set `.http_token(..)` to expose it (loopback-only needs none).
345        // Fail fast, before any core or socket is created.
346        if self.config.http_enabled && self.config.announce_http && self.config.http_token.is_none()
347        {
348            return Err(KoiError::InsecureConfig(
349                "announce_http exposes the embedded HTTP adapter on 0.0.0.0; call \
350                 .http_token(..) to require x-koi-token, or drop announce_http to bind loopback"
351                    .into(),
352            ));
353        }
354
355        // Build every domain core + cross-domain bridge + the domain background tasks
356        // (orchestrator, certmesh role loops) through the one shared composition root the
357        // daemon and the Windows service use, so the three boot paths construct an identical
358        // graph. `fail_fast` is the only embedded-specific knob: a library surfaces a failed
359        // capability as an error rather than logging it and dropping the capability.
360        let cores = koi_compose::cores::build_cores(
361            &koi_compose::cores::CoreSpec {
362                no_mdns: !self.config.mdns_enabled,
363                no_certmesh: !self.config.certmesh_enabled,
364                no_dns: !self.config.dns_enabled,
365                no_health: !self.config.health_enabled,
366                no_proxy: !self.config.proxy_enabled,
367                no_udp: !self.config.udp_enabled,
368                no_runtime: !self.config.runtime_enabled,
369                data_dir: self.config.data_dir.clone(),
370                dns_config: self.config.dns_config.clone(),
371                runtime: self.config.runtime_backend.to_string(),
372                http_port: self.config.http_port,
373                // Pin the DNS state path to the data dir captured at construction time so it is
374                // immune to KOI_DATA_DIR env-var races in parallel tests.
375                dns_state_path: self
376                    .config
377                    .data_dir
378                    .as_ref()
379                    .map(|dir| dir.join("state").join("dns.json")),
380                proxy_data_dir: self.config.data_dir.clone(),
381                dns_auto_start: self.config.dns_auto_start,
382                health_auto_start: self.config.health_auto_start,
383                proxy_auto_start: self.config.proxy_auto_start,
384                spawn_orchestrator: self.config.orchestrator_enabled,
385                spawn_certmesh_loops: self.config.certmesh_background_enabled,
386                fail_fast: true,
387            },
388            &cancel,
389            &mut tasks,
390        )
391        .await?;
392        let koi_compose::cores::Cores {
393            mdns,
394            certmesh,
395            dns,
396            health,
397            proxy,
398            udp,
399            runtime,
400            mdns_snapshot: mdns_bridge,
401        } = cores;
402
403        // Build dashboard state if enabled
404        let dashboard_state = if self.config.dashboard_enabled && self.config.http_enabled {
405            let started_at = std::time::Instant::now();
406            let snap_mdns = mdns.clone();
407            let snap_certmesh = certmesh.clone();
408            let snap_dns = dns.clone();
409            let snap_health = health.clone();
410            let snap_proxy = proxy.clone();
411            let snap_udp = udp.clone();
412            let snap_runtime = runtime.clone();
413
414            let snapshot_fn: koi_dashboard::dashboard::SnapshotFn = Arc::new(move || {
415                let m = snap_mdns.clone();
416                let cm = snap_certmesh.clone();
417                let d = snap_dns.clone();
418                let h = snap_health.clone();
419                let p = snap_proxy.clone();
420                let u = snap_udp.clone();
421                let rt = snap_runtime.clone();
422                Box::pin(async move { build_embedded_snapshot(m, cm, d, h, p, u, rt).await })
423            });
424
425            let (dash_event_tx, _) = broadcast::channel(256);
426            let ds = koi_dashboard::dashboard::DashboardState {
427                identity: koi_dashboard::dashboard::DashboardIdentity {
428                    version: env!("CARGO_PKG_VERSION").to_string(),
429                    platform: std::env::consts::OS.to_string(),
430                },
431                mode: "embedded",
432                snapshot_fn,
433                event_tx: dash_event_tx.clone(),
434                started_at,
435            };
436
437            // Spawn the single unified event forwarder (superset incl. runtime),
438            // shared with the daemon — no more inline copy here.
439            tasks.push(koi_dashboard::forward::spawn_event_forwarder(
440                koi_dashboard::forward::ForwarderCores {
441                    mdns: mdns.clone(),
442                    certmesh: certmesh.clone(),
443                    dns: dns.clone(),
444                    health: health.clone(),
445                    proxy: proxy.clone(),
446                    runtime: runtime.clone(),
447                },
448                dash_event_tx,
449                cancel.clone(),
450            ));
451
452            Some(ds)
453        } else {
454            None
455        };
456
457        // Build browser state if enabled (requires mDNS). The LAN-wide meta-browse is
458        // lazy — it starts on the first browser request, not here.
459        let browser_state = if self.config.mdns_browser_enabled && self.config.http_enabled {
460            if let Some(ref mdns_core) = mdns {
461                Some(koi_dashboard::browser::build_state(
462                    mdns_core.clone(),
463                    cancel.clone(),
464                ))
465            } else {
466                tracing::warn!("mdns_browser enabled but mDNS is disabled — skipping browser");
467                None
468            }
469        } else {
470            None
471        };
472
473        // Spawn the embedded HTTP adapter via the shared koi-serve router — the SAME
474        // implementation the daemon uses, so there is no separate embedded server and no
475        // /v1/status drift. Secure-by-default: bind loopback unless the host opts into LAN
476        // exposure via `announce_http`; mutations are unauthenticated unless `http_token`
477        // is set.
478        //
479        // The actually-bound address is captured so the handle can report it — the
480        // root fix for ephemeral binding: `Builder::http_port(0)` lets the OS assign
481        // a free port and `KoiHandle::bound_http_port()` reports it (no probing).
482        let mut http_addr: Option<std::net::SocketAddr> = None;
483        if self.config.http_enabled {
484            let http_cancel = cancel.clone();
485            let http_cores = koi_compose::cores::Cores {
486                mdns: mdns.clone(),
487                certmesh: certmesh.clone(),
488                dns: dns.clone(),
489                health: health.clone(),
490                proxy: proxy.clone(),
491                udp: udp.clone(),
492                runtime: runtime.clone(),
493                mdns_snapshot: mdns_bridge.clone(),
494            };
495            // Exposure is gated at the top of start(): announce_http without a token
496            // fails closed before we get here, so an exposed bind always carries auth.
497            let exposed = self.config.announce_http;
498            let bind_ip = if exposed {
499                std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
500            } else {
501                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
502            };
503            let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
504            let http_cfg = koi_serve::http::HttpConfig {
505                bind_ip,
506                port: self.config.http_port,
507                started_at: std::time::Instant::now(),
508                dashboard: dashboard_state,
509                browser: browser_state,
510                auth: self.config.http_token.clone(),
511                mdns_snapshot: mdns_bridge.clone(),
512                mcp_http: false,
513                admin_shutdown: false,
514                api_docs: self.config.api_docs_enabled,
515                daemon: false,
516                ready: Some(ready_tx),
517            };
518            tasks.push(tokio::spawn(async move {
519                if let Err(e) = koi_serve::http::start(http_cores, http_cfg, http_cancel).await {
520                    tracing::error!(error = %e, "embedded HTTP adapter failed");
521                }
522            }));
523            // Wait for the listener to bind so the handle reports the real port
524            // (the OS-assigned one when http_port == 0). A bind failure drops the
525            // sender → `None` here; the spawned task has already logged the error.
526            http_addr = ready_rx.await.ok();
527        }
528
529        // ── Self-announce supervisor: _http._tcp, posture-reactive ──
530        // One supervisor publishes this host's _http._tcp record (with the ADR-020 posture
531        // stamp) and re-stamps it on every Open↔Authenticated flip — the same reactivity the
532        // daemon and the Windows service get, shared via koi-compose. `_mcp._tcp` stays off:
533        // embedded mounts no /v1/mcp transport, so it must not advertise one.
534        let announce_cores = koi_compose::cores::Cores {
535            mdns: mdns.clone(),
536            certmesh: certmesh.clone(),
537            dns: dns.clone(),
538            health: health.clone(),
539            proxy: proxy.clone(),
540            udp: udp.clone(),
541            runtime: runtime.clone(),
542            mdns_snapshot: mdns_bridge.clone(),
543        };
544        // Advertise the ACTUAL bound port: with http_port(0) the OS picked an
545        // ephemeral port at bind time, so announcing the configured 0 would publish
546        // an unreachable _http._tcp/_mcp._tcp record. `http_addr` holds the resolved
547        // address (Some whenever the HTTP adapter bound); fall back to the configured
548        // port when HTTP is disabled (announce_http requires http_enabled anyway).
549        let announce_http_port = http_addr.map(|a| a.port()).unwrap_or(self.config.http_port);
550        koi_compose::self_announce::spawn(
551            &announce_cores,
552            koi_compose::self_announce::SelfAnnounceConfig {
553                http_port: announce_http_port,
554                dashboard_enabled: self.config.dashboard_enabled,
555                announce_http: self.config.announce_http
556                    && self.config.http_enabled
557                    && self.config.mdns_enabled,
558                announce_mcp: false,
559                dns_zone: self.config.dns_config.zone.clone(),
560            },
561            cancel.clone(),
562            &mut tasks,
563        );
564
565        // ── Domain event → host KoiEvent forwarders ──
566        // One shared spawn helper instead of six copies of the streaming select! skeleton.
567        // Each domain core is present only when its capability is enabled, so `if let Some`
568        // is the only gate needed.
569        if let Some(core) = &mdns {
570            spawn_event_mapper(
571                core.subscribe(),
572                map_mdns_event,
573                event_tx.clone(),
574                self.event_handler.clone(),
575                cancel.clone(),
576                &mut tasks,
577            );
578        }
579        if let Some(runtime) = &health {
580            spawn_event_mapper(
581                runtime.core().subscribe(),
582                |e| Some(map_health_event(e)),
583                event_tx.clone(),
584                self.event_handler.clone(),
585                cancel.clone(),
586                &mut tasks,
587            );
588        }
589        if let Some(runtime) = &dns {
590            spawn_event_mapper(
591                runtime.core().subscribe(),
592                |e| Some(map_dns_event(e)),
593                event_tx.clone(),
594                self.event_handler.clone(),
595                cancel.clone(),
596                &mut tasks,
597            );
598        }
599        if let Some(core) = &certmesh {
600            spawn_event_mapper(
601                core.subscribe(),
602                |e| Some(map_certmesh_event(e)),
603                event_tx.clone(),
604                self.event_handler.clone(),
605                cancel.clone(),
606                &mut tasks,
607            );
608            // Posture transitions (Open↔Authenticated) surface as PostureChanged —
609            // the live trust-state signal the consumer's serve supervisor and any
610            // observer react to (ADR-020 §5/§13).
611            spawn_posture_watcher(
612                core.watch_posture(),
613                event_tx.clone(),
614                self.event_handler.clone(),
615                cancel.clone(),
616                &mut tasks,
617            );
618        }
619        if let Some(runtime_proxy) = &proxy {
620            spawn_event_mapper(
621                runtime_proxy.core().subscribe(),
622                |e| Some(map_proxy_event(e)),
623                event_tx.clone(),
624                self.event_handler.clone(),
625                cancel.clone(),
626                &mut tasks,
627            );
628        }
629        if let Some(runtime_core) = &runtime {
630            spawn_event_mapper(
631                runtime_core.subscribe(),
632                map_runtime_event,
633                event_tx.clone(),
634                self.event_handler.clone(),
635                cancel.clone(),
636                &mut tasks,
637            );
638        }
639
640        // The runtime orchestrator and the certmesh role loops (trust-bundle pull + renewal)
641        // are spawned inside `build_cores` (gated on the spec's `spawn_orchestrator` /
642        // `spawn_certmesh_loops`, set from the builder opt-ins above). Only the opt-in-without-
643        // prerequisite warnings stay here.
644        if self.config.orchestrator_enabled && runtime.is_none() {
645            tracing::warn!(
646                "orchestrator enabled but the runtime adapter is not — skipping orchestrator"
647            );
648        }
649
650        // ── Certmesh enrollment-approval pump (opt-in) ──
651        // The trust-bundle pull + cert-renewal loops are spawned by `build_cores`; the approval
652        // pump is NOT (its decider is host-specific). Embedded has no console, so it
653        // auto-denies. Off by default; a clustered embedded CA host opts in.
654        if self.config.certmesh_background_enabled {
655            if let Some(ref certmesh_core) = certmesh {
656                koi_compose::certmesh::spawn_enrollment_approval(
657                    certmesh_core,
658                    koi_compose::certmesh::deny_and_log_decider(),
659                    &cancel,
660                    &mut tasks,
661                )
662                .await;
663            } else {
664                tracing::warn!(
665                    "certmesh_background enabled but certmesh is not — skipping certmesh loops"
666                );
667            }
668        }
669
670        Ok(KoiHandle::new_embedded(
671            mdns,
672            dns,
673            health,
674            certmesh,
675            proxy,
676            udp,
677            runtime,
678            http_addr,
679            self.config.data_dir.clone(),
680            event_tx,
681            cancel,
682            tasks,
683        ))
684    }
685}
686
687fn map_mdns_event(event: MdnsEvent) -> Option<KoiEvent> {
688    match event {
689        MdnsEvent::Found(record) => Some(KoiEvent::MdnsFound(record)),
690        MdnsEvent::Resolved(record) => Some(KoiEvent::MdnsResolved(record)),
691        MdnsEvent::Removed { name, service_type } => {
692            Some(KoiEvent::MdnsRemoved { name, service_type })
693        }
694    }
695}
696
697fn map_health_event(event: koi_health::HealthEvent) -> KoiEvent {
698    match event {
699        koi_health::HealthEvent::StatusChanged { name, status } => {
700            KoiEvent::HealthChanged { name, status }
701        }
702    }
703}
704
705fn map_dns_event(event: koi_dns::DnsEvent) -> KoiEvent {
706    match event {
707        koi_dns::DnsEvent::EntryUpdated { name, ip } => KoiEvent::DnsEntryUpdated { name, ip },
708        koi_dns::DnsEvent::EntryRemoved { name } => KoiEvent::DnsEntryRemoved { name },
709    }
710}
711
712fn map_certmesh_event(event: koi_certmesh::CertmeshEvent) -> KoiEvent {
713    match event {
714        koi_certmesh::CertmeshEvent::MemberJoined {
715            hostname,
716            fingerprint,
717        } => KoiEvent::CertmeshMemberJoined {
718            hostname,
719            fingerprint,
720        },
721        koi_certmesh::CertmeshEvent::MemberRevoked { hostname } => {
722            KoiEvent::CertmeshMemberRevoked { hostname }
723        }
724        koi_certmesh::CertmeshEvent::Destroyed => KoiEvent::CertmeshDestroyed,
725    }
726}
727
728fn map_proxy_event(event: koi_proxy::ProxyEvent) -> KoiEvent {
729    match event {
730        koi_proxy::ProxyEvent::EntryUpdated { entry } => KoiEvent::ProxyEntryUpdated { entry },
731        koi_proxy::ProxyEvent::EntryRemoved { name } => KoiEvent::ProxyEntryRemoved { name },
732    }
733}
734
735fn map_runtime_event(event: koi_runtime::RuntimeEvent) -> Option<KoiEvent> {
736    match event {
737        koi_runtime::RuntimeEvent::Started(instance) => Some(KoiEvent::RuntimeInstanceStarted {
738            name: instance.name,
739            backend: instance.backend,
740        }),
741        koi_runtime::RuntimeEvent::Stopped { name, .. } => {
742            Some(KoiEvent::RuntimeInstanceStopped { name })
743        }
744        // Updated, BackendDisconnected, BackendReconnected are operational events
745        // not surfaced as KoiEvents (dashboard SSE covers them)
746        _ => None,
747    }
748}
749
750/// Spawn a task that maps a domain's broadcast events into the host `KoiEvent` stream until
751/// cancellation. One shared skeleton replaces the six near-identical per-domain `select!`
752/// loops that `start()` used to inline (the charter calls out duplicating that skeleton).
753///
754/// `map` returns `None` to drop an event (e.g. mDNS `Found`, which has no host-facing
755/// variant); event types that always map wrap their mapper as `|e| Some(map_x(e))`.
756fn spawn_event_mapper<E, F>(
757    mut rx: broadcast::Receiver<E>,
758    map: F,
759    tx: broadcast::Sender<KoiEvent>,
760    handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
761    cancel: CancellationToken,
762    tasks: &mut Vec<JoinHandle<()>>,
763) where
764    E: Clone + Send + 'static,
765    F: Fn(E) -> Option<KoiEvent> + Send + 'static,
766{
767    tasks.push(tokio::spawn(async move {
768        loop {
769            tokio::select! {
770                _ = cancel.cancelled() => break,
771                msg = rx.recv() => {
772                    let Ok(event) = msg else { continue; };
773                    if let Some(mapped) = map(event) {
774                        emit_event(&tx, handler.as_ref(), mapped);
775                    }
776                }
777            }
778        }
779    }));
780}
781
782/// Spawn a task translating this node's posture-watch transitions into
783/// `KoiEvent::PostureChanged` until cancellation (ADR-020 §5). A `watch` (which
784/// holds the latest value and coalesces) rather than a broadcast, so it needs its
785/// own loop instead of [`spawn_event_mapper`]. The first borrow seeds the baseline
786/// so the initial value is not mis-reported as a transition.
787fn spawn_posture_watcher(
788    mut rx: tokio::sync::watch::Receiver<koi_common::posture::Posture>,
789    tx: broadcast::Sender<KoiEvent>,
790    handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
791    cancel: CancellationToken,
792    tasks: &mut Vec<JoinHandle<()>>,
793) {
794    tasks.push(tokio::spawn(async move {
795        let mut last = *rx.borrow_and_update();
796        loop {
797            tokio::select! {
798                _ = cancel.cancelled() => break,
799                res = rx.changed() => {
800                    if res.is_err() {
801                        break; // the certmesh core was dropped
802                    }
803                    let to = *rx.borrow_and_update();
804                    if to != last {
805                        emit_event(&tx, handler.as_ref(), KoiEvent::PostureChanged { from: last, to });
806                        last = to;
807                    }
808                }
809            }
810        }
811    }));
812}
813
814fn emit_event(
815    tx: &broadcast::Sender<KoiEvent>,
816    handler: Option<&Arc<dyn Fn(KoiEvent) + Send + Sync>>,
817    event: KoiEvent,
818) {
819    if let Some(handler) = handler {
820        handler(event.clone());
821    }
822    let _ = tx.send(event);
823}
824
825pub(crate) fn map_join_error(err: tokio::task::JoinError) -> KoiError {
826    KoiError::Io(std::io::Error::other(err.to_string()))
827}
828
829/// Build a dashboard snapshot from the embedded domain cores.
830///
831/// Delegates to `koi_compose::snapshot::build_dashboard_snapshot`, the one detail projection
832/// shared with the daemon dashboard, so the embedded snapshot now carries the same
833/// health / DNS / certmesh / proxy / UDP detail (not just the capability ladder).
834async fn build_embedded_snapshot(
835    mdns: Option<Arc<koi_mdns::MdnsCore>>,
836    certmesh: Option<Arc<koi_certmesh::CertmeshCore>>,
837    dns: Option<Arc<koi_dns::DnsRuntime>>,
838    health: Option<Arc<koi_health::HealthRuntime>>,
839    proxy: Option<Arc<koi_proxy::ProxyRuntime>>,
840    udp: Option<Arc<koi_udp::UdpRuntime>>,
841    runtime: Option<Arc<koi_runtime::RuntimeCore>>,
842) -> serde_json::Value {
843    let cores = koi_compose::cores::Cores {
844        mdns,
845        certmesh,
846        dns,
847        health,
848        proxy,
849        udp,
850        runtime,
851        mdns_snapshot: None,
852    };
853    koi_compose::snapshot::build_dashboard_snapshot(&cores).await
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use koi_common::types::ServiceRecord;
860    use std::collections::HashMap;
861
862    fn sample_record() -> ServiceRecord {
863        ServiceRecord {
864            name: "Test Service".to_string(),
865            service_type: "_http._tcp".to_string(),
866            host: Some("host.local".to_string()),
867            ip: Some("10.0.0.1".to_string()),
868            port: Some(8080),
869            txt: HashMap::new(),
870        }
871    }
872
873    // ── KoiError Display ───────────────────────────────────────────
874
875    #[test]
876    fn koi_error_disabled_capability_display() {
877        let err = KoiError::DisabledCapability("mdns");
878        assert_eq!(err.to_string(), "capability disabled: mdns");
879    }
880
881    #[test]
882    fn koi_error_io_from_impl() {
883        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
884        let err: KoiError = io_err.into();
885        assert!(matches!(err, KoiError::Io(_)));
886        assert!(err.to_string().contains("file missing"));
887    }
888
889    #[test]
890    fn koi_error_debug_does_not_panic() {
891        let err = KoiError::DisabledCapability("proxy");
892        let debug = format!("{err:?}");
893        assert!(debug.contains("DisabledCapability"));
894    }
895
896    // ── certmesh data-dir SSOT (custom data_dir honored end-to-end) ──
897
898    #[tokio::test]
899    async fn init_certmesh_core_honors_custom_data_dir_end_to_end() {
900        // The point of the path-SSOT refactor: a host that injects its own
901        // data_dir gets the CA created, discovered, and unlocked under THAT
902        // dir — never a split between the injected dir and an ambient default.
903        let base = koi_common::test::ensure_data_dir("koi-embedded-datadir-tests");
904        let data_dir = base.join("custom-data");
905        let paths = koi_certmesh::CertmeshPaths::with_data_dir(data_dir.clone());
906
907        // Fresh machine: no CA yet. The uninitialized early-return must still
908        // carry the injected paths — this is the regression the dropped-paths
909        // bug (uninitialized branches dropping `paths`) used to fail.
910        let fresh =
911            koi_compose::cores::init_certmesh_core(Some(&data_dir)).expect("uninitialized core");
912        assert_eq!(
913            fresh.paths().data_dir(),
914            data_dir.as_path(),
915            "uninitialized core must keep the injected data_dir"
916        );
917
918        // Create a CA + roster UNDER the injected dir.
919        koi_certmesh::ca::create_ca("test-pass-strong", &[7u8; 32], &paths)
920            .expect("create CA under injected dir");
921        // My Organization posture: closed enrollment, approval required.
922        let roster = koi_certmesh::roster::Roster::new(false, true, Some("ops".to_string()));
923        koi_certmesh::roster::save_roster(&roster, &paths.roster_path())
924            .expect("save roster under injected dir");
925
926        // Reopen on the same injected dir: the CA is discovered there and the
927        // core unlocks from it — proving the data root is honored end-to-end.
928        let reopened =
929            koi_compose::cores::init_certmesh_core(Some(&data_dir)).expect("locked core");
930        assert_eq!(reopened.paths().data_dir(), data_dir.as_path());
931        reopened
932            .unlock("test-pass-strong")
933            .await
934            .expect("unlock CA from the injected data_dir");
935    }
936
937    // ── map_mdns_event ─────────────────────────────────────────────
938
939    #[test]
940    fn map_mdns_found() {
941        let record = sample_record();
942        let event = koi_mdns::MdnsEvent::Found(record.clone());
943        let mapped = map_mdns_event(event);
944        assert!(mapped.is_some());
945        match mapped.unwrap() {
946            KoiEvent::MdnsFound(r) => assert_eq!(r.name, "Test Service"),
947            other => panic!("expected MdnsFound, got {other:?}"),
948        }
949    }
950
951    #[test]
952    fn map_mdns_resolved() {
953        let record = sample_record();
954        let event = koi_mdns::MdnsEvent::Resolved(record);
955        let mapped = map_mdns_event(event);
956        assert!(mapped.is_some());
957        match mapped.unwrap() {
958            KoiEvent::MdnsResolved(r) => {
959                assert_eq!(r.port, Some(8080));
960                assert_eq!(r.service_type, "_http._tcp");
961            }
962            other => panic!("expected MdnsResolved, got {other:?}"),
963        }
964    }
965
966    #[test]
967    fn map_mdns_removed() {
968        let event = koi_mdns::MdnsEvent::Removed {
969            name: "Gone Service".to_string(),
970            service_type: "_http._tcp".to_string(),
971        };
972        let mapped = map_mdns_event(event);
973        assert!(mapped.is_some());
974        match mapped.unwrap() {
975            KoiEvent::MdnsRemoved { name, service_type } => {
976                assert_eq!(name, "Gone Service");
977                assert_eq!(service_type, "_http._tcp");
978            }
979            other => panic!("expected MdnsRemoved, got {other:?}"),
980        }
981    }
982
983    // ── map_health_event ───────────────────────────────────────────
984
985    #[test]
986    fn map_health_status_changed_up() {
987        let event = koi_health::HealthEvent::StatusChanged {
988            name: "api".to_string(),
989            status: koi_health::HealthStatus::Up,
990        };
991        let mapped = map_health_event(event);
992        match mapped {
993            KoiEvent::HealthChanged { name, status } => {
994                assert_eq!(name, "api");
995                assert!(matches!(status, koi_health::HealthStatus::Up));
996            }
997            other => panic!("expected HealthChanged, got {other:?}"),
998        }
999    }
1000
1001    #[test]
1002    fn map_health_status_changed_down() {
1003        let event = koi_health::HealthEvent::StatusChanged {
1004            name: "db".to_string(),
1005            status: koi_health::HealthStatus::Down,
1006        };
1007        let mapped = map_health_event(event);
1008        match mapped {
1009            KoiEvent::HealthChanged { name, status } => {
1010                assert_eq!(name, "db");
1011                assert!(matches!(status, koi_health::HealthStatus::Down));
1012            }
1013            other => panic!("expected HealthChanged, got {other:?}"),
1014        }
1015    }
1016
1017    // ── map_dns_event ──────────────────────────────────────────────
1018
1019    #[test]
1020    fn map_dns_entry_updated() {
1021        let event = koi_dns::DnsEvent::EntryUpdated {
1022            name: "grafana".to_string(),
1023            ip: "10.0.0.5".to_string(),
1024        };
1025        let mapped = map_dns_event(event);
1026        match mapped {
1027            KoiEvent::DnsEntryUpdated { name, ip } => {
1028                assert_eq!(name, "grafana");
1029                assert_eq!(ip, "10.0.0.5");
1030            }
1031            other => panic!("expected DnsEntryUpdated, got {other:?}"),
1032        }
1033    }
1034
1035    #[test]
1036    fn map_dns_entry_removed() {
1037        let event = koi_dns::DnsEvent::EntryRemoved {
1038            name: "old-host".to_string(),
1039        };
1040        let mapped = map_dns_event(event);
1041        match mapped {
1042            KoiEvent::DnsEntryRemoved { name } => {
1043                assert_eq!(name, "old-host");
1044            }
1045            other => panic!("expected DnsEntryRemoved, got {other:?}"),
1046        }
1047    }
1048
1049    // ── map_certmesh_event ─────────────────────────────────────────
1050
1051    #[test]
1052    fn map_certmesh_member_joined() {
1053        let event = koi_certmesh::CertmeshEvent::MemberJoined {
1054            hostname: "node-a".to_string(),
1055            fingerprint: "sha256:abc".to_string(),
1056        };
1057        let mapped = map_certmesh_event(event);
1058        match mapped {
1059            KoiEvent::CertmeshMemberJoined {
1060                hostname,
1061                fingerprint,
1062            } => {
1063                assert_eq!(hostname, "node-a");
1064                assert_eq!(fingerprint, "sha256:abc");
1065            }
1066            other => panic!("expected CertmeshMemberJoined, got {other:?}"),
1067        }
1068    }
1069
1070    #[test]
1071    fn map_certmesh_member_revoked() {
1072        let event = koi_certmesh::CertmeshEvent::MemberRevoked {
1073            hostname: "node-b".to_string(),
1074        };
1075        let mapped = map_certmesh_event(event);
1076        match mapped {
1077            KoiEvent::CertmeshMemberRevoked { hostname } => {
1078                assert_eq!(hostname, "node-b");
1079            }
1080            other => panic!("expected CertmeshMemberRevoked, got {other:?}"),
1081        }
1082    }
1083
1084    #[test]
1085    fn map_certmesh_destroyed() {
1086        let event = koi_certmesh::CertmeshEvent::Destroyed;
1087        let mapped = map_certmesh_event(event);
1088        assert!(matches!(mapped, KoiEvent::CertmeshDestroyed));
1089    }
1090
1091    #[tokio::test]
1092    async fn posture_watcher_emits_upgrade_and_degrade() {
1093        use koi_common::posture::Posture;
1094        let (tx_p, rx_p) = tokio::sync::watch::channel(Posture::OPEN);
1095        let (ev_tx, mut ev_rx) = broadcast::channel(16);
1096        let cancel = CancellationToken::new();
1097        let mut tasks = Vec::new();
1098        spawn_posture_watcher(rx_p, ev_tx, None, cancel.clone(), &mut tasks);
1099        // Let the watcher run to its first await so it captures OPEN as the
1100        // baseline before we send (current-thread test runtime: yield runs the
1101        // spawned task up to `rx.changed()`).
1102        tokio::task::yield_now().await;
1103
1104        // Open→Authenticated → an upgrade PostureChanged.
1105        tx_p.send(Posture::new(true, false)).unwrap();
1106        let ev = tokio::time::timeout(std::time::Duration::from_secs(1), ev_rx.recv())
1107            .await
1108            .expect("event arrives")
1109            .expect("recv ok");
1110        assert!(
1111            matches!(ev, KoiEvent::PostureChanged { from, to } if !from.signed && to.signed),
1112            "expected upgrade, got {ev:?}"
1113        );
1114
1115        // Authenticated→Open → a degrade PostureChanged (as loud as the upgrade).
1116        tx_p.send(Posture::OPEN).unwrap();
1117        let ev = tokio::time::timeout(std::time::Duration::from_secs(1), ev_rx.recv())
1118            .await
1119            .expect("event arrives")
1120            .expect("recv ok");
1121        assert!(
1122            matches!(ev, KoiEvent::PostureChanged { from, to } if from.signed && !to.signed),
1123            "expected degrade, got {ev:?}"
1124        );
1125
1126        cancel.cancel();
1127        for t in tasks {
1128            let _ = t.await;
1129        }
1130    }
1131
1132    // ── map_proxy_event ────────────────────────────────────────────
1133
1134    #[test]
1135    fn map_proxy_entry_updated() {
1136        let entry = koi_proxy::ProxyEntry {
1137            name: "web".to_string(),
1138            listen_port: 443,
1139            backend: "http://localhost:3000".to_string(),
1140            allow_remote: true,
1141        };
1142        let event = koi_proxy::ProxyEvent::EntryUpdated {
1143            entry: entry.clone(),
1144        };
1145        let mapped = map_proxy_event(event);
1146        match mapped {
1147            KoiEvent::ProxyEntryUpdated { entry } => {
1148                assert_eq!(entry.name, "web");
1149                assert_eq!(entry.listen_port, 443);
1150                assert!(entry.allow_remote);
1151            }
1152            other => panic!("expected ProxyEntryUpdated, got {other:?}"),
1153        }
1154    }
1155
1156    #[test]
1157    fn map_proxy_entry_removed() {
1158        let event = koi_proxy::ProxyEvent::EntryRemoved {
1159            name: "old-proxy".to_string(),
1160        };
1161        let mapped = map_proxy_event(event);
1162        match mapped {
1163            KoiEvent::ProxyEntryRemoved { name } => {
1164                assert_eq!(name, "old-proxy");
1165            }
1166            other => panic!("expected ProxyEntryRemoved, got {other:?}"),
1167        }
1168    }
1169
1170    // ── map_join_error ─────────────────────────────────────────────
1171
1172    #[test]
1173    fn map_join_error_produces_io_error() {
1174        // We can't easily create a real JoinError, but we can test the function
1175        // signature exists and the KoiError::Io variant wraps correctly.
1176        let io_err = std::io::Error::other("simulated join error");
1177        let koi_err = KoiError::Io(io_err);
1178        assert!(koi_err.to_string().contains("simulated join error"));
1179    }
1180
1181    // ── Builder defaults ───────────────────────────────────────────
1182
1183    #[test]
1184    fn builder_default_config() {
1185        let builder = Builder::new();
1186        let embedded = builder.build().expect("build should succeed");
1187        assert!(embedded.config.mdns_enabled);
1188        assert!(!embedded.config.http_enabled);
1189        assert_eq!(embedded.config.http_port, 5641);
1190    }
1191
1192    #[test]
1193    fn builder_default_trait() {
1194        let builder = Builder::default();
1195        let embedded = builder.build().expect("build should succeed");
1196        assert_eq!(embedded.config.service_endpoint, "http://127.0.0.1:5641");
1197    }
1198
1199    #[test]
1200    fn builder_fluent_overrides() {
1201        let embedded = Builder::new()
1202            .http(true)
1203            .mdns(false)
1204            .dns_enabled(false)
1205            .health(true)
1206            .certmesh(true)
1207            .proxy(true)
1208            .udp(true)
1209            .http_port(9000)
1210            .dashboard(true)
1211            .api_docs(true)
1212            .mdns_browser(true)
1213            .announce_http(true)
1214            .dns_auto_start(true)
1215            .health_auto_start(true)
1216            .proxy_auto_start(true)
1217            .service_endpoint("http://10.0.0.1:8080")
1218            .service_mode(ServiceMode::EmbeddedOnly)
1219            .data_dir("/tmp/koi-test")
1220            .build()
1221            .expect("build should succeed");
1222
1223        assert!(embedded.config.http_enabled);
1224        assert!(!embedded.config.mdns_enabled);
1225        assert!(!embedded.config.dns_enabled);
1226        assert!(embedded.config.health_enabled);
1227        assert!(embedded.config.certmesh_enabled);
1228        assert!(embedded.config.proxy_enabled);
1229        assert!(embedded.config.udp_enabled);
1230        assert_eq!(embedded.config.http_port, 9000);
1231        assert!(embedded.config.dashboard_enabled);
1232        assert!(embedded.config.api_docs_enabled);
1233        assert!(embedded.config.mdns_browser_enabled);
1234        assert!(embedded.config.announce_http);
1235        assert!(embedded.config.dns_auto_start);
1236        assert!(embedded.config.health_auto_start);
1237        assert!(embedded.config.proxy_auto_start);
1238        assert_eq!(embedded.config.service_endpoint, "http://10.0.0.1:8080");
1239        assert_eq!(embedded.config.service_mode, ServiceMode::EmbeddedOnly);
1240        assert_eq!(
1241            embedded.config.data_dir,
1242            Some(std::path::PathBuf::from("/tmp/koi-test"))
1243        );
1244    }
1245
1246    #[test]
1247    fn orchestrator_and_certmesh_background_are_opt_in() {
1248        // Default: both off (a leaf embedded host only wants the event stream).
1249        let default_cfg = Builder::new().build().expect("build should succeed");
1250        assert!(!default_cfg.config.orchestrator_enabled);
1251        assert!(!default_cfg.config.certmesh_background_enabled);
1252
1253        // Opt-in: both on when requested.
1254        let opted = Builder::new()
1255            .runtime_auto()
1256            .orchestrator(true)
1257            .certmesh(true)
1258            .certmesh_background(true)
1259            .build()
1260            .expect("build should succeed");
1261        assert!(opted.config.orchestrator_enabled);
1262        assert!(opted.config.certmesh_background_enabled);
1263    }
1264
1265    #[test]
1266    fn builder_dns_configure_closure() {
1267        let embedded = Builder::new()
1268            .dns(|b| b.port(5353).zone("home").local_ttl(120))
1269            .build()
1270            .expect("build should succeed");
1271
1272        assert_eq!(embedded.config.dns_config.port, 5353);
1273        assert_eq!(embedded.config.dns_config.zone, "home");
1274        assert_eq!(embedded.config.dns_config.local_ttl, 120);
1275    }
1276
1277    #[test]
1278    fn builder_event_handler() {
1279        use std::sync::atomic::{AtomicBool, Ordering};
1280        let called = Arc::new(AtomicBool::new(false));
1281        let called_clone = called.clone();
1282
1283        let embedded = Builder::new()
1284            .events(move |_event| {
1285                called_clone.store(true, Ordering::SeqCst);
1286            })
1287            .build()
1288            .expect("build should succeed");
1289
1290        assert!(embedded.event_handler.is_some());
1291    }
1292
1293    #[test]
1294    fn builder_extra_firewall_ports() {
1295        use koi_common::firewall::{FirewallPort, FirewallProtocol};
1296        let extra = vec![FirewallPort::new("Custom", FirewallProtocol::Tcp, 12345)];
1297        let _builder = Builder::new().extra_firewall_ports(extra);
1298        // Just verifying the method compiles and does not panic.
1299    }
1300
1301    // ── Result type alias ──────────────────────────────────────────
1302
1303    #[test]
1304    fn result_type_works_with_ok() {
1305        let result: Result<i32> = Ok(42);
1306        assert!(matches!(result, Ok(42)));
1307    }
1308
1309    #[test]
1310    fn result_type_works_with_err() {
1311        let result: Result<i32> = Err(KoiError::DisabledCapability("test"));
1312        assert!(result.is_err());
1313    }
1314}