Skip to main content

actr_hyper/
config.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4#[cfg(not(target_arch = "wasm32"))]
5use std::sync::Arc;
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::Duration;
8
9#[cfg(not(target_arch = "wasm32"))]
10use crate::error::{HyperError, HyperResult};
11#[cfg(not(target_arch = "wasm32"))]
12use crate::verify::TrustProvider;
13
14/// Default storage path template: `{data_dir}/{actr_type}`.
15#[cfg(not(target_arch = "wasm32"))]
16const DEFAULT_STORAGE_TEMPLATE: &str = "{data_dir}/{actr_type}";
17
18/// Hyper initialization configuration.
19#[cfg(not(target_arch = "wasm32"))]
20#[derive(Clone)]
21pub struct HyperConfig {
22    /// Root data directory, corresponds to the namespace template variable `{data_dir}`
23    pub data_dir: PathBuf,
24
25    /// Storage namespace path template, defaults to `{data_dir}/{actr_type}`
26    ///
27    /// Available variables:
28    /// - `{data_dir}`      — root data directory
29    /// - `{instance_id}`   — locally unique ID generated and persisted at Hyper startup
30    /// - `{hostname}`      — OS hostname
31    /// - `{manufacturer}`  — Actor manufacturer name
32    /// - `{actr_name}`     — Actor name
33    /// - `{version}`       — Actor version
34    /// - `{actr_type}`     — full three-part type (`{manufacturer}/{actr_name}/{version}`)
35    /// - `{realm_id}`      — Actor's realm (available at runtime)
36    /// - `{env.VAR}`       — any environment variable
37    pub storage_path_template: String,
38
39    /// Pluggable package-signature verifier. Replaces the old `TrustMode` enum.
40    ///
41    /// Construct via [`crate::verify::StaticTrust`], [`crate::verify::RegistryTrust`],
42    /// or [`crate::verify::ChainTrust`] (or bring your own).
43    pub trust_provider: Arc<dyn TrustProvider>,
44
45    /// How far in advance of expiry the framework should fire the
46    /// `on_credential_expiring` hook.
47    ///
48    /// Default: 5 minutes.
49    pub credential_expiry_warning: Duration,
50
51    /// Queue-length trip point for the `on_mailbox_backpressure` hook.
52    ///
53    /// When `Some(threshold)`, the hook fires once per incident as soon as
54    /// the mailbox queued-message count crosses `threshold`, and re-arms
55    /// once the queue falls back below. When `None`, a built-in default of
56    /// [`DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD`] messages is used. The
57    /// mailbox trait currently exposes `status()` which reports
58    /// queued_messages, so the polling-based implementation in
59    /// `lifecycle::node` works against any mailbox backend that supports
60    /// the base trait.
61    pub mailbox_backpressure_threshold: Option<usize>,
62}
63
64/// Default mailbox backpressure threshold (queued-message count).
65///
66/// Chosen conservatively — most Actor-RTC workloads are below this in
67/// steady state, so a warning at this level means queue growth needs
68/// attention. Tune per-actor via
69/// [`HyperConfig::mailbox_backpressure_threshold`].
70#[cfg(not(target_arch = "wasm32"))]
71pub(crate) const DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD: usize = 1024;
72
73/// Default credential-expiry warning lead time.
74#[cfg(not(target_arch = "wasm32"))]
75pub(crate) const DEFAULT_CREDENTIAL_EXPIRY_WARNING: Duration = Duration::from_secs(5 * 60);
76
77#[cfg(not(target_arch = "wasm32"))]
78impl std::fmt::Debug for HyperConfig {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("HyperConfig")
81            .field("data_dir", &self.data_dir)
82            .field("storage_path_template", &self.storage_path_template)
83            .field("trust_provider", &self.trust_provider)
84            .field("credential_expiry_warning", &self.credential_expiry_warning)
85            .field(
86                "mailbox_backpressure_threshold",
87                &self.mailbox_backpressure_threshold,
88            )
89            .finish()
90    }
91}
92
93/// Raw TOML shape for the optional `[hyper]` section of `actr.toml`.
94///
95/// All fields optional; each controls one knob of [`HyperConfig`]. The
96/// trust anchor lives inside `[hyper.trust]` (singular) and uses the
97/// same tag-dispatched schema as the top-level `[[trust]]` array so users
98/// can pick whichever style they prefer.
99#[cfg(not(target_arch = "wasm32"))]
100#[derive(Debug, Clone, Default, serde::Deserialize)]
101pub(crate) struct HyperSection {
102    /// Root data directory (`{data_dir}` template variable).
103    #[serde(default)]
104    pub data_dir: Option<std::path::PathBuf>,
105
106    /// Override the default `{data_dir}/{actr_type}` storage template.
107    #[serde(default)]
108    pub storage_path_template: Option<String>,
109
110    /// Single trust anchor; for chain composition use the top-level
111    /// `[[trust]]` array instead.
112    #[serde(default)]
113    pub trust: Option<HyperTrustAnchor>,
114}
115
116/// Trust anchor config for `[hyper.trust]`. Superset of
117/// `actr_config::TrustAnchor` with an extra `dev_only` kind that lets
118/// tests and examples opt in to [`crate::verify::StaticTrust::dev_only`]
119/// explicitly.
120#[cfg(not(target_arch = "wasm32"))]
121#[derive(Debug, Clone, serde::Deserialize)]
122#[serde(tag = "kind", rename_all = "snake_case")]
123pub(crate) enum HyperTrustAnchor {
124    /// Accept any package — for tests and local development **only**.
125    ///
126    /// When selected by `Node::from_config_file`, emits a prominent
127    /// `tracing::warn!` at load time.
128    DevOnly,
129    /// Pre-shared Ed25519 public key. See
130    /// [`actr_config::TrustAnchor::Static`] for field semantics.
131    Static {
132        #[serde(default)]
133        pubkey_file: Option<std::path::PathBuf>,
134        #[serde(default)]
135        pubkey_b64: Option<String>,
136    },
137    /// AIS HTTP registry endpoint. See
138    /// [`actr_config::TrustAnchor::Registry`].
139    Registry { endpoint: String },
140}
141
142/// Top-level wrapper used when parsing `actr.toml` for the `[hyper]`
143/// section in isolation. Ignores every other field.
144#[cfg(not(target_arch = "wasm32"))]
145#[derive(Debug, Clone, Default, serde::Deserialize)]
146pub(crate) struct HyperSectionWrapper {
147    #[serde(default)]
148    pub hyper: HyperSection,
149}
150
151/// Load an `actr.toml`-style file and return a [`crate::Node`] in the
152/// `Init` state. This function handles the full pipeline:
153///
154/// 1. Parse the runtime section via [`actr_config::ConfigParser`].
155/// 2. Parse the optional `[hyper]` section for data-dir / template /
156///    trust overrides.
157/// 3. Resolve trust anchors (preferring `[hyper.trust]`, falling back to
158///    top-level `[[trust]]`, finally producing an `Err` unless the
159///    effective config explicitly opts into dev-only trust).
160/// 4. Build a [`crate::Hyper`] and wrap it in `Node<Init>`.
161#[cfg(not(target_arch = "wasm32"))]
162pub(crate) async fn node_from_config_file(
163    path: &Path,
164) -> crate::error::HyperResult<crate::Node<crate::Init>> {
165    node_from_config_file_with_package(path, None).await
166}
167
168/// Same as [`node_from_config_file`] but lets the caller supply the
169/// [`actr_config::PackageInfo`] that becomes the node's registered
170/// `actr_type`. Used by the language bindings, which already parsed
171/// `manifest.toml` and don't want their identity collapsed to the
172/// placeholder.
173#[cfg(not(target_arch = "wasm32"))]
174pub(crate) async fn node_from_config_file_with_package(
175    path: &Path,
176    package_info: Option<actr_config::PackageInfo>,
177) -> crate::error::HyperResult<crate::Node<crate::Init>> {
178    use crate::error::HyperError;
179    use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
180
181    // Load both the RuntimeConfig and the raw TOML so we can pick up
182    // [hyper.*] overrides that `ConfigParser` doesn't surface.
183    let raw_text = std::fs::read_to_string(path).map_err(|e| {
184        HyperError::Config(format!(
185            "failed to read runtime config `{}`: {e}",
186            path.display()
187        ))
188    })?;
189
190    let raw_runtime: actr_config::RuntimeRawConfig = raw_text.parse().map_err(|e| {
191        HyperError::Config(format!(
192            "failed to parse runtime config `{}`: {e}",
193            path.display()
194        ))
195    })?;
196    // `RuntimeConfig` requires a PackageInfo. When the caller has one
197    // already (bindings come in here with the manifest's `[package]`),
198    // honour it so the node registers under the real actr_type. Without
199    // an explicit one, fall back to the historical `local:Client:0.0.0`
200    // placeholder so callers without a sibling manifest still work.
201    let package_info = package_info.unwrap_or_else(|| actr_config::PackageInfo {
202        name: "client".to_string(),
203        actr_type: actr_protocol::ActrType {
204            manufacturer: "local".to_string(),
205            name: "Client".to_string(),
206            version: "0.0.0".to_string(),
207        },
208        description: None,
209        authors: vec![],
210        license: None,
211    });
212    let runtime_config = actr_config::ConfigParser::parse_runtime(raw_runtime, path, package_info)
213        .map_err(|e| HyperError::Config(format!("failed to parse runtime config: {e}")))?;
214
215    // Parse the optional [hyper] section.
216    let hyper_section: HyperSectionWrapper = toml::from_str(&raw_text).map_err(|e| {
217        HyperError::Config(format!(
218            "failed to parse [hyper] section of `{}`: {e}",
219            path.display()
220        ))
221    })?;
222    let hyper_section = hyper_section.hyper;
223
224    // Resolve the data_dir: [hyper].data_dir > CLI user-config default.
225    let data_dir = if let Some(dir) = hyper_section.data_dir.clone() {
226        dir
227    } else {
228        actr_config::user_config::resolve_hyper_data_dir().map_err(|e| {
229            HyperError::Config(format!(
230                "failed to resolve default hyper data_dir (set `[hyper].data_dir` explicitly): {e}"
231            ))
232        })?
233    };
234
235    // Resolve trust: prefer [hyper.trust], fall back to top-level [[trust]].
236    let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
237    let trust: Arc<dyn TrustProvider> = if let Some(anchor) = hyper_section.trust.clone() {
238        match anchor {
239            HyperTrustAnchor::DevOnly => {
240                tracing::warn!(
241                    "[hyper.trust] kind = \"dev_only\" selected; accepting any package — \
242                     NEVER use in production"
243                );
244                Arc::new(StaticTrust::dev_only())
245            }
246            HyperTrustAnchor::Static {
247                pubkey_file,
248                pubkey_b64,
249            } => {
250                let key_bytes = load_static_pubkey_bytes(
251                    pubkey_file.as_deref().map(|p| resolve_path(base_dir, p)),
252                    pubkey_b64,
253                )?;
254                Arc::new(StaticTrust::new(key_bytes)?)
255            }
256            HyperTrustAnchor::Registry { endpoint } => {
257                let base = endpoint.trim_end_matches("/ais").to_string();
258                Arc::new(RegistryTrust::new(base))
259            }
260        }
261    } else if !runtime_config.trust.is_empty() {
262        // Chain fallback from the top-level [[trust]] anchors.
263        let mut providers: Vec<Arc<dyn TrustProvider>> =
264            Vec::with_capacity(runtime_config.trust.len());
265        for anchor in &runtime_config.trust {
266            let provider: Arc<dyn TrustProvider> = match anchor {
267                actr_config::TrustAnchor::Static {
268                    pubkey_file,
269                    pubkey_b64,
270                } => {
271                    let key_bytes =
272                        load_static_pubkey_bytes(pubkey_file.clone(), pubkey_b64.clone())?;
273                    Arc::new(StaticTrust::new(key_bytes)?)
274                }
275                actr_config::TrustAnchor::Registry { endpoint } => {
276                    let base = endpoint.trim_end_matches("/ais").to_string();
277                    Arc::new(RegistryTrust::new(base))
278                }
279            };
280            providers.push(provider);
281        }
282        if providers.len() == 1 {
283            providers.into_iter().next().unwrap()
284        } else {
285            Arc::new(ChainTrust::new(providers))
286        }
287    } else {
288        return Err(HyperError::Config(
289            "no `[hyper.trust]` or `[[trust]]` anchor configured. \
290             Every runtime must declare a package-signature trust policy. \
291             For dev / tests set `[hyper.trust] kind = \"dev_only\"`; \
292             for production use `kind = \"static\"` with a `pubkey_file` \
293             or `kind = \"registry\"` with an AIS endpoint."
294                .to_string(),
295        ));
296    };
297
298    // Build HyperConfig with the resolved values.
299    let mut hyper_config = HyperConfig::new(&data_dir, trust);
300    if let Some(template) = hyper_section.storage_path_template {
301        hyper_config = hyper_config.with_storage_template(template);
302    }
303
304    // Build Hyper and return Node<Init>. Observability bring-up is left
305    // to the caller — bindings and the CLI both want control over when
306    // the tracing subscriber gets installed (they may want to layer in
307    // their own filters first).
308    let hyper = crate::Hyper::new(hyper_config).await?;
309    let _ = &base_dir;
310    Ok(crate::Node::from_hyper(hyper, runtime_config))
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314fn resolve_path(base_dir: &Path, path: impl AsRef<Path>) -> std::path::PathBuf {
315    let p = path.as_ref();
316    if p.is_absolute() {
317        p.to_path_buf()
318    } else {
319        base_dir.join(p)
320    }
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324fn load_static_pubkey_bytes(
325    pubkey_file: Option<std::path::PathBuf>,
326    pubkey_b64: Option<String>,
327) -> crate::error::HyperResult<Vec<u8>> {
328    use crate::error::HyperError;
329    use base64::Engine;
330
331    if let Some(b64) = pubkey_b64 {
332        let bytes = base64::engine::general_purpose::STANDARD
333            .decode(&b64)
334            .map_err(|e| HyperError::Config(format!("invalid pubkey_b64: {e}")))?;
335        if bytes.len() != 32 {
336            return Err(HyperError::Config(format!(
337                "pubkey_b64 must decode to 32 bytes, got {}",
338                bytes.len()
339            )));
340        }
341        return Ok(bytes);
342    }
343    let path = pubkey_file.ok_or_else(|| {
344        HyperError::Config("static trust anchor requires `pubkey_file` or `pubkey_b64`".to_string())
345    })?;
346    let text = std::fs::read_to_string(&path).map_err(|e| {
347        HyperError::Config(format!(
348            "failed to read pubkey_file `{}`: {e}",
349            path.display()
350        ))
351    })?;
352    let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
353        HyperError::Config(format!(
354            "pubkey_file `{}` is not valid JSON: {e}",
355            path.display()
356        ))
357    })?;
358    let b64 = value
359        .get("public_key")
360        .and_then(|v| v.as_str())
361        .ok_or_else(|| {
362            HyperError::Config(format!(
363                "pubkey_file `{}` is missing the `public_key` field",
364                path.display()
365            ))
366        })?;
367    let bytes = base64::engine::general_purpose::STANDARD
368        .decode(b64)
369        .map_err(|e| {
370            HyperError::Config(format!(
371                "pubkey_file `{}` has invalid base64: {e}",
372                path.display()
373            ))
374        })?;
375    if bytes.len() != 32 {
376        return Err(HyperError::Config(format!(
377            "pubkey_file `{}` must contain a 32-byte key, got {}",
378            path.display(),
379            bytes.len()
380        )));
381    }
382    Ok(bytes)
383}
384
385#[cfg(not(target_arch = "wasm32"))]
386impl HyperConfig {
387    /// Build a new HyperConfig with the given `data_dir` and package trust provider.
388    ///
389    /// There is no default provider — you must explicitly decide how packages
390    /// are authenticated (see [`crate::verify::StaticTrust`] /
391    /// [`crate::verify::RegistryTrust`] / [`crate::verify::ChainTrust`]).
392    pub fn new(data_dir: impl AsRef<Path>, trust_provider: Arc<dyn TrustProvider>) -> Self {
393        Self {
394            data_dir: data_dir.as_ref().to_path_buf(),
395            storage_path_template: DEFAULT_STORAGE_TEMPLATE.to_string(),
396            trust_provider,
397            credential_expiry_warning: DEFAULT_CREDENTIAL_EXPIRY_WARNING,
398            mailbox_backpressure_threshold: None,
399        }
400    }
401
402    pub fn with_storage_template(mut self, template: impl Into<String>) -> Self {
403        self.storage_path_template = template.into();
404        self
405    }
406
407    pub fn with_trust_provider(mut self, trust_provider: Arc<dyn TrustProvider>) -> Self {
408        self.trust_provider = trust_provider;
409        self
410    }
411
412    /// Override the credential-expiry warning lead time.
413    pub fn with_credential_expiry_warning(mut self, window: Duration) -> Self {
414        self.credential_expiry_warning = window;
415        self
416    }
417
418    /// Set the mailbox backpressure threshold.
419    ///
420    /// See [`HyperConfig::mailbox_backpressure_threshold`] for semantics.
421    pub fn with_mailbox_backpressure_threshold(mut self, threshold: Option<usize>) -> Self {
422        self.mailbox_backpressure_threshold = threshold;
423        self
424    }
425
426    /// Resolve the active mailbox backpressure threshold — explicit
427    /// override or the built-in [`DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD`].
428    pub fn resolved_mailbox_backpressure_threshold(&self) -> usize {
429        self.mailbox_backpressure_threshold
430            .unwrap_or(DEFAULT_MAILBOX_BACKPRESSURE_THRESHOLD)
431    }
432}
433
434#[cfg(not(target_arch = "wasm32"))]
435/// Namespace template resolver
436///
437/// Holds runtime-known variables and resolves path templates on demand.
438/// Templates are resolved once during Hyper initialization and remain fixed afterwards.
439pub(crate) struct NamespaceResolver {
440    vars: HashMap<String, String>,
441}
442
443#[cfg(not(target_arch = "wasm32"))]
444impl NamespaceResolver {
445    pub fn new(config: &HyperConfig, instance_id: &str) -> HyperResult<Self> {
446        let mut vars = HashMap::new();
447
448        vars.insert(
449            "data_dir".to_string(),
450            config
451                .data_dir
452                .to_str()
453                .ok_or_else(|| {
454                    HyperError::Config("data_dir path contains non-UTF-8 characters".to_string())
455                })?
456                .to_string(),
457        );
458        vars.insert("instance_id".to_string(), instance_id.to_string());
459
460        if let Ok(hostname) = std::env::var("HOSTNAME").or_else(|_| {
461            // fallback: read system hostname
462            std::fs::read_to_string("/etc/hostname")
463                .map(|s| s.trim().to_string())
464                .map_err(|_| std::env::VarError::NotPresent)
465        }) {
466            vars.insert("hostname".to_string(), hostname);
467        }
468
469        Ok(Self { vars })
470    }
471
472    /// Inject Actor type variables (extracted from the verified manifest)
473    pub fn with_actor_type(mut self, manufacturer: &str, actr_name: &str, version: &str) -> Self {
474        self.vars
475            .insert("manufacturer".to_string(), manufacturer.to_string());
476        self.vars
477            .insert("actr_name".to_string(), actr_name.to_string());
478        self.vars.insert("version".to_string(), version.to_string());
479        self.vars.insert(
480            "actr_type".to_string(),
481            format!("{manufacturer}/{actr_name}/{version}"),
482        );
483        self
484    }
485
486    /// Inject runtime realm_id
487    #[allow(dead_code)]
488    pub fn with_realm(mut self, realm_id: u64) -> Self {
489        self.vars
490            .insert("realm_id".to_string(), realm_id.to_string());
491        self
492    }
493
494    /// Resolve a template string, returning the final path
495    pub fn resolve(&self, template: &str) -> HyperResult<PathBuf> {
496        let mut result = template.to_string();
497
498        // Handle {env.VAR} variables
499        let env_prefix = "{env.";
500        let mut pos = 0;
501        while let Some(start) = result[pos..].find(env_prefix) {
502            let abs_start = pos + start;
503            if let Some(end) = result[abs_start..].find('}') {
504                let var_name = &result[abs_start + env_prefix.len()..abs_start + end];
505                let value = std::env::var(var_name)
506                    .map_err(|_| HyperError::TemplateVariable(format!("env.{var_name}")))?;
507                let placeholder = format!("{{env.{var_name}}}");
508                result = result.replacen(&placeholder, &value, 1);
509                // do not advance position, re-scan the replaced string
510            } else {
511                pos = abs_start + 1;
512            }
513        }
514
515        // Handle regular variables
516        for (key, value) in &self.vars {
517            result = result.replace(&format!("{{{key}}}"), value);
518        }
519
520        // Check for unresolved variables
521        if let Some(start) = result.find('{') {
522            if let Some(end) = result[start..].find('}') {
523                let var = &result[start + 1..start + end];
524                return Err(HyperError::TemplateVariable(var.to_string()));
525            }
526        }
527
528        Ok(PathBuf::from(result))
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::verify::StaticTrust;
536
537    fn stub_config(data_dir: &str) -> HyperConfig {
538        HyperConfig::new(data_dir, Arc::new(StaticTrust::dev_only()))
539    }
540
541    #[test]
542    fn resolve_basic_template() {
543        let config = stub_config("/var/lib/actr");
544        let resolver = NamespaceResolver::new(&config, "abc123")
545            .unwrap()
546            .with_actor_type("acme", "Sensor", "1.0.0");
547
548        let path = resolver.resolve("{data_dir}/{actr_type}").unwrap();
549        assert_eq!(path, PathBuf::from("/var/lib/actr/acme/Sensor/1.0.0"));
550    }
551
552    #[test]
553    fn resolve_missing_var_returns_error() {
554        let config = stub_config("/tmp");
555        let resolver = NamespaceResolver::new(&config, "id1").unwrap();
556        let result = resolver.resolve("{data_dir}/{realm_id}");
557        assert!(matches!(result, Err(HyperError::TemplateVariable(_))));
558    }
559
560    #[test]
561    fn resolve_with_realm() {
562        let config = stub_config("/tmp");
563        let resolver = NamespaceResolver::new(&config, "id1")
564            .unwrap()
565            .with_actor_type("acme", "Worker", "2.0")
566            .with_realm(42);
567        let path = resolver
568            .resolve("{data_dir}/{actr_type}/{realm_id}")
569            .unwrap();
570        assert_eq!(path, PathBuf::from("/tmp/acme/Worker/2.0/42"));
571    }
572
573    /// Minimal actr.toml body shared across tests. Callers append a
574    /// `[hyper]` section (with an escaped `data_dir` pointing into the
575    /// test's tempdir) so `node_from_config_file` never writes into the
576    /// user's real `~/.actr`.
577    const BASE_CONFIG_TOML: &str = r#"
578edition = 1
579[signaling]
580url = "ws://localhost:8081/signaling/ws"
581[ais_endpoint]
582url = "http://localhost:8081/ais"
583[deployment]
584realm_id = 1
585"#;
586
587    #[tokio::test]
588    async fn node_from_config_file_dev_only_succeeds() {
589        let dir = tempfile::tempdir().unwrap();
590        let path = dir.path().join("actr.toml");
591        let data_dir = dir.path().display().to_string().replace('\\', "/");
592        std::fs::write(
593            &path,
594            format!(
595                "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
596                 [hyper.trust]\nkind = \"dev_only\"\n"
597            ),
598        )
599        .unwrap();
600        let _node = node_from_config_file(&path)
601            .await
602            .expect("dev_only trust should be accepted");
603    }
604
605    #[tokio::test]
606    async fn node_from_config_file_missing_trust_errors() {
607        let dir = tempfile::tempdir().unwrap();
608        let path = dir.path().join("actr.toml");
609        let data_dir = dir.path().display().to_string().replace('\\', "/");
610        std::fs::write(
611            &path,
612            format!("{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n"),
613        )
614        .unwrap();
615        let result = node_from_config_file(&path).await;
616        let err = match result {
617            Ok(_) => panic!("missing trust must fail"),
618            Err(e) => e,
619        };
620        let msg = err.to_string();
621        assert!(
622            msg.contains("no `[hyper.trust]`") && msg.contains("dev_only"),
623            "error should direct user to the dev_only opt-in, got: {msg}"
624        );
625    }
626
627    #[tokio::test]
628    async fn node_from_config_file_accepts_top_level_registry_anchor() {
629        let dir = tempfile::tempdir().unwrap();
630        let path = dir.path().join("actr.toml");
631        let data_dir = dir.path().display().to_string().replace('\\', "/");
632        std::fs::write(
633            &path,
634            format!(
635                "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
636                 [[trust]]\nkind = \"registry\"\nendpoint = \"http://localhost:8081/ais\"\n"
637            ),
638        )
639        .unwrap();
640        let _node = node_from_config_file(&path)
641            .await
642            .expect("top-level [[trust]] registry anchor should be accepted");
643    }
644
645    #[tokio::test]
646    async fn node_from_config_file_allows_linked_actor_type_override() {
647        let dir = tempfile::tempdir().unwrap();
648        let path = dir.path().join("actr.toml");
649        let data_dir = dir.path().display().to_string().replace('\\', "/");
650        std::fs::write(
651            &path,
652            format!(
653                "{BASE_CONFIG_TOML}[hyper]\ndata_dir = \"{data_dir}\"\n\
654                 [hyper.trust]\nkind = \"dev_only\"\n"
655            ),
656        )
657        .unwrap();
658
659        let actor_type = actr_protocol::ActrType {
660            manufacturer: "acme".to_string(),
661            name: "EchoApp".to_string(),
662            version: "0.1.0".to_string(),
663        };
664
665        let node = node_from_config_file(&path)
666            .await
667            .expect("dev_only trust should be accepted")
668            .with_actor_type(actor_type.clone());
669
670        assert_eq!(node.runtime_config().actr_type(), &actor_type);
671    }
672}