Skip to main content

actr_hyper/
lib.rs

1//! # actr-hyper
2//!
3//! Hyper — Actor platform layer + runtime infrastructure
4//!
5//! ## Positioning
6//!
7//! Hyper is the operating system for Actors: it defines boundaries (Sandbox), provides platform
8//! primitives, and carries the full runtime infrastructure (transport, routing, lifecycle management).
9//!
10//! An Actor cannot open a database on its own, cannot hold its own private key, and cannot claim
11//! to be a certain type — everything must go through Hyper's controlled interfaces.
12//!
13//! ## Responsibilities
14//!
15//! ### Platform Layer (formerly Hyper)
16//!
17//! - Package signature verification (binary_hash + MFR signature)
18//! - Actor bootstrap (registers with AIS on behalf of the Actor, obtains credential)
19//! - Storage namespace isolation (independent SQLite space per Actor)
20//! - Cryptographic primitives (Ed25519 sign/verify, Actor does not hold raw private keys)
21//! - Runtime lifecycle management (ActrNode lifecycle for Executor execution bodies)
22//!
23//! ### Runtime Infrastructure (formerly actr-runtime)
24//!
25//! - **Actor Lifecycle**: system init, node start/stop (ActrNode / ActrRef)
26//! - **Message Transport**: layered architecture (Wire -> Transport -> Gate -> Dispatch)
27//! - **Communication Modes**: in-process (zero-copy) and cross-process (WebRTC / WebSocket)
28//! - **Message Persistence**: SQLite-backed Mailbox (ACID guarantees)
29//! - **Observability**: logging, distributed tracing (OpenTelemetry, optional feature)
30//! - **WASM Engine**: WASM actor execution (optional feature)
31//!
32//! ## Architecture Layers
33//!
34//! ```text
35//! ┌─────────────────────────────────────────────────────┐
36//! │  Platform (Hyper)                                   │  AIS Bootstrap
37//! │  Sandbox / Verify / Storage / KeyCache              │  Package Verify
38//! ├─────────────────────────────────────────────────────┤
39//! │  Lifecycle Management (ActrNode → ActrRef)
40//! ├─────────────────────────────────────────────────────┤
41//! │  Layer 3: Inbound Dispatch                          │  DataStreamRegistry
42//! │           (Fast Path Routing)                       │  MediaFrameRegistry
43//! ├─────────────────────────────────────────────────────┤
44//! │  Layer 2: Outbound Adapters (internal)             │  HostGate
45//! │           (Message Sending)                         │  PeerGate
46//! ├─────────────────────────────────────────────────────┤
47//! │  Layer 1: Transport                                 │  Lane (core abstraction)
48//! │           (Channel Management)                      │  HostTransport
49//! │                                                     │  PeerTransport
50//! ├─────────────────────────────────────────────────────┤
51//! │  Layer 0: Wire                                      │  WebRtcGate
52//! │           (Physical Connections)                     │  WebRtcCoordinator
53//! │                                                     │  SignalingClient
54//! └─────────────────────────────────────────────────────┘
55//! ```
56//!
57//! ## Non-Goals
58//!
59//! Hyper does not understand business logic, does not perform business-level message routing,
60//! and is unaware of business relationships between Actors.
61//! The `hyper_send`/`hyper_recv` provided in WASM mode are network I/O primitives;
62//! routing decisions are made by the ActrNode running inside the WASM.
63
64// ═══════════════════════════════════════════════════════════════════════════════
65// Platform modules (cross-platform)
66// ═══════════════════════════════════════════════════════════════════════════════
67
68pub mod config;
69pub mod error;
70
71// Runtime error re-exports (from actr_protocol, distinct from HyperError)
72pub mod runtime_error;
73
74// Verify module: TrustProvider trait + built-in verifiers (native-only).
75// The verified manifest / package types live in `actr_pack` and are
76// re-exported below for downstream consumers.
77pub mod verify;
78
79// ═══════════════════════════════════════════════════════════════════════════════
80// Native-only modules (excluded on wasm32)
81// ═══════════════════════════════════════════════════════════════════════════════
82
83#[cfg(not(target_arch = "wasm32"))]
84pub mod actr_ref;
85#[cfg(not(target_arch = "wasm32"))]
86pub mod ais_client;
87#[cfg(not(target_arch = "wasm32"))]
88pub(crate) mod key_cache;
89#[cfg(not(target_arch = "wasm32"))]
90pub mod storage;
91
92// Runtime infrastructure modules (native-only)
93#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
94pub mod inbound;
95#[cfg(all(not(target_arch = "wasm32"), not(feature = "test-utils")))]
96pub(crate) mod inbound;
97#[cfg(not(target_arch = "wasm32"))]
98pub mod lifecycle;
99#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
100pub mod outbound;
101#[cfg(all(not(target_arch = "wasm32"), not(feature = "test-utils")))]
102pub(crate) mod outbound;
103#[cfg(not(target_arch = "wasm32"))]
104pub mod transport;
105#[cfg(not(target_arch = "wasm32"))]
106pub mod wire;
107
108// Shared helpers for integration tests (native-only)
109#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
110pub mod test_support;
111
112// Context (native-only, depends on transport/wire)
113#[cfg(not(target_arch = "wasm32"))]
114pub mod context;
115
116// Runtime workload abstraction (native-only, WASM/dynclib host)
117#[cfg(not(target_arch = "wasm32"))]
118pub mod workload;
119
120// ServiceSpec derivation from a verified package (native-only; pulls
121// actr-service-compat/proto-fingerprint).
122#[cfg(not(target_arch = "wasm32"))]
123mod service_spec;
124
125// WASM actor execution engine (optional, native-only)
126#[cfg(all(not(target_arch = "wasm32"), feature = "wasm-engine"))]
127pub mod wasm;
128
129// Dynclib actor execution engine (optional, native-only)
130#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
131pub mod dynclib;
132
133// Observability is public so bindings can bootstrap tracing. Monitoring
134// and resource management are reserved scaffolding; they stay crate-private.
135#[cfg(not(target_arch = "wasm32"))]
136pub(crate) mod monitoring;
137#[cfg(not(target_arch = "wasm32"))]
138pub mod observability;
139#[cfg(not(target_arch = "wasm32"))]
140pub(crate) mod resource;
141
142// ═══════════════════════════════════════════════════════════════════════════════
143// Re-exports: Cross-platform
144// ═══════════════════════════════════════════════════════════════════════════════
145
146pub use actr_pack::{PackageManifest, VerifiedPackage};
147pub use config::HyperConfig;
148pub use error::HyperError;
149pub(crate) use error::HyperResult;
150
151// Core protocol types
152pub use actr_protocol::{Acl, ActrId, ActrType, ServiceSpec};
153
154// Re-export MediaSample and MediaType from framework (dependency inversion)
155pub use actr_framework::{MediaSample, MediaType};
156
157// Runtime error types (distinct from HyperError — these are actor-facing errors)
158pub use runtime_error::{ActorResult, ActrError, Classify, ErrorKind};
159
160// Platform traits re-exports
161pub use actr_platform_traits::{CryptoProvider, KvStore, PlatformError, PlatformProvider};
162
163// ═══════════════════════════════════════════════════════════════════════════════
164// Re-exports: Native-only
165// ═══════════════════════════════════════════════════════════════════════════════
166
167#[cfg(not(target_arch = "wasm32"))]
168pub use ais_client::AisClient;
169#[cfg(not(target_arch = "wasm32"))]
170pub use storage::ActorStore;
171#[cfg(not(target_arch = "wasm32"))]
172pub use verify::{ChainTrust, MfrCertCache, RegistryTrust, StaticTrust, TrustProvider};
173
174// Observability
175#[cfg(not(target_arch = "wasm32"))]
176pub use observability::{ObservabilityGuard, init_observability};
177
178#[cfg(not(target_arch = "wasm32"))]
179pub use actr_ref::ActrRef;
180// Runtime core structures
181#[cfg(not(target_arch = "wasm32"))]
182pub use lifecycle::{CredentialState, NetworkEventHandle};
183
184// Layer 1: Transport layer
185#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
186pub use transport::{
187    ConnType, DataLane, DefaultWireBuilder, DefaultWireBuilderConfig, HostTransport, PeerTransport,
188    WireBuilder, WireHandle,
189};
190#[cfg(not(target_arch = "wasm32"))]
191pub use transport::{Dest, ExponentialBackoff, NetworkError, NetworkResult};
192
193// Layer 0: Wire layer
194#[cfg(not(target_arch = "wasm32"))]
195pub use wire::{
196    AuthConfig, AuthType, DisconnectReason, ReconnectConfig, SignalingClient, SignalingConfig,
197    SignalingEvent, SignalingStats, WebRtcConfig,
198};
199#[cfg(all(not(target_arch = "wasm32"), feature = "test-utils"))]
200pub use wire::{WebRtcCoordinator, WebSocketSignalingClient};
201
202// Mailbox (from actr-runtime-mailbox crate)
203#[cfg(not(target_arch = "wasm32"))]
204pub use actr_runtime_mailbox::{
205    Mailbox, MailboxStats, MessagePriority, MessageRecord, MessageStatus,
206};
207
208// Bootstrap context builder (lifecycle hooks + ActrRef app-side context) is
209// crate-internal; consumers go through the Node / ActrRef lifecycle.
210
211// Runtime workload abstraction
212#[cfg(not(target_arch = "wasm32"))]
213pub use workload::{HostAbiFn, HostOperation, HostOperationResult, InvocationContext};
214
215// ═══════════════════════════════════════════════════════════════════════════════
216// Constants
217// ═══════════════════════════════════════════════════════════════════════════════
218
219pub(crate) const INITIAL_CONNECTION_TIMEOUT: std::time::Duration =
220    std::time::Duration::from_secs(10);
221
222// ═══════════════════════════════════════════════════════════════════════════════
223// Prelude
224// ═══════════════════════════════════════════════════════════════════════════════
225
226pub mod prelude {
227    //! Convenience prelude module
228    //!
229    //! Re-exports commonly used types and traits for quick imports:
230    //!
231    //! ```rust
232    //! use actr_hyper::prelude::*;
233    //! ```
234
235    // ── Platform types (cross-platform) ─────────────────────────────────────
236    pub use crate::verify::{ChainTrust, RegistryTrust, StaticTrust, TrustProvider};
237    #[cfg(not(target_arch = "wasm32"))]
238    pub use crate::{Attached, Hyper, Init, Node, Registered, storage::ActorStore};
239    pub use crate::{HyperConfig, HyperError};
240    pub use actr_pack::{PackageManifest, VerifiedPackage};
241
242    // ── Core structures (native-only) ───────────────────────────────────────
243    #[cfg(not(target_arch = "wasm32"))]
244    pub use crate::actr_ref::ActrRef;
245
246    // Re-export MediaSample and MediaType from framework (dependency inversion)
247    pub use actr_framework::{MediaSample, MediaType};
248
249    // ── Layer 0: Wire / WebRTC (native-only) ────────────────────────────────
250    #[cfg(not(target_arch = "wasm32"))]
251    pub use crate::wire::webrtc::{
252        AuthConfig, AuthType, DisconnectReason, ReconnectConfig, SignalingClient, SignalingConfig,
253        SignalingEvent, SignalingStats, WebRtcConfig,
254    };
255    #[cfg(feature = "test-utils")]
256    pub use crate::wire::webrtc::{WebRtcCoordinator, WebSocketSignalingClient};
257
258    // ── Mailbox (native-only) ───────────────────────────────────────────────
259    #[cfg(not(target_arch = "wasm32"))]
260    pub use actr_runtime_mailbox::{
261        Mailbox, MailboxStats, MessagePriority, MessageRecord, MessageStatus,
262    };
263
264    // ── Layer 1: Transport (native-only) ────────────────────────────────────
265    #[cfg(feature = "test-utils")]
266    pub use crate::transport::{
267        ConnType, DataLane, DefaultWireBuilder, DefaultWireBuilderConfig, HostTransport,
268        PeerTransport, WireBuilder, WireHandle,
269    };
270    #[cfg(not(target_arch = "wasm32"))]
271    pub use crate::transport::{Dest, NetworkError, NetworkResult};
272
273    // ── Error types ─────────────────────────────────────────────────────────
274    pub use crate::runtime_error::{ActorResult, ActrError};
275
276    // ── Base types ──────────────────────────────────────────────────────────
277    pub use actr_protocol::ActrId;
278
279    // ── Framework traits (for implementing Workload) ────────────────────────
280    pub use actr_framework::{Context, Workload};
281
282    // ── Async trait support ─────────────────────────────────────────────────
283    pub use async_trait::async_trait;
284
285    // ── Common utilities ────────────────────────────────────────────────────
286    pub use anyhow::{Context as AnyhowContext, Result as AnyhowResult};
287    pub use chrono::{DateTime, Utc};
288    pub use uuid::Uuid;
289
290    // ── Tokio runtime primitives ────────────────────────────────────────────
291    pub use tokio::sync::{Mutex, RwLock, broadcast, mpsc, oneshot};
292    #[cfg(not(target_arch = "wasm32"))]
293    pub use tokio::time::{Duration, Instant, sleep, timeout};
294
295    // ── Logging ─────────────────────────────────────────────────────────────
296    pub use tracing::{debug, error, info, trace, warn};
297}
298
299// ═══════════════════════════════════════════════════════════════════════════════
300// Hyper runtime instance (platform singleton) — native-only
301// ═══════════════════════════════════════════════════════════════════════════════
302
303#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
304use std::io::Write;
305#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
306use std::path::Path;
307#[cfg(not(target_arch = "wasm32"))]
308use std::path::PathBuf;
309#[cfg(not(target_arch = "wasm32"))]
310use std::str::FromStr;
311#[cfg(not(target_arch = "wasm32"))]
312use std::sync::Arc;
313#[cfg(not(target_arch = "wasm32"))]
314use std::time::{SystemTime, UNIX_EPOCH};
315
316#[cfg(not(target_arch = "wasm32"))]
317use prost::Message;
318#[cfg(not(target_arch = "wasm32"))]
319use tracing::{debug, error, info, warn};
320#[cfg(not(target_arch = "wasm32"))]
321use uuid::Uuid;
322
323#[cfg(not(target_arch = "wasm32"))]
324use actr_platform_traits::KvOp;
325#[cfg(not(target_arch = "wasm32"))]
326use actr_protocol::{Realm, RegisterAuthMode, RegisterRequest, register_response};
327
328#[cfg(not(target_arch = "wasm32"))]
329/// Compile-time state marker: a [`Node`] has been born from a [`Hyper`]
330/// plus a [`actr_config::RuntimeConfig`], but no attachment path has been
331/// chosen yet. Transition to [`Attached`] via [`Node::attach`] or
332/// [`Node::link`].
333pub struct Init;
334#[cfg(not(target_arch = "wasm32"))]
335/// Compile-time state marker: a package has been verified and attached; AIS credential still pending.
336pub struct Attached;
337#[cfg(not(target_arch = "wasm32"))]
338/// Compile-time state marker: AIS credential has been obtained and injected; ready to start.
339pub struct Registered;
340
341#[cfg(not(target_arch = "wasm32"))]
342mod node_state_sealed {
343    pub trait Sealed {}
344    impl Sealed for super::Init {}
345    impl Sealed for super::Attached {}
346    impl Sealed for super::Registered {}
347}
348
349#[cfg(not(target_arch = "wasm32"))]
350/// Sealed trait describing valid [`Node`] lifecycle states.
351pub trait NodeState: node_state_sealed::Sealed {}
352#[cfg(not(target_arch = "wasm32"))]
353impl NodeState for Init {}
354#[cfg(not(target_arch = "wasm32"))]
355impl NodeState for Attached {}
356#[cfg(not(target_arch = "wasm32"))]
357impl NodeState for Registered {}
358
359#[cfg(not(target_arch = "wasm32"))]
360/// Hyper — pre-workload framework infrastructure.
361///
362/// `Hyper` is the operating system that runs an Actor: it owns configuration,
363/// instance identity, trust material, and the package verifier. It is
364/// deliberately generic-free and has no knowledge of a specific workload.
365///
366/// User code constructs Hyper only in the escape-hatch path
367/// ([`Node::from_hyper`]); prefer [`Node::from_config_file`] for the common
368/// case where config lives in `actr.toml`. The full typestate chain is:
369///
370/// ```text
371/// Node::from_config_file(path)    -> Node<Init>              (framework only)
372/// Node::from_hyper(hyper, config) -> Node<Init>              (escape hatch)
373///     .attach(package)            -> Node<Attached>          (attach: wasm / dyn lib)
374///     .link(workload)             -> Node<Attached>          (link: static lib)
375///     .register(ais_endpoint)     -> Node<Registered>        (credential obtained)
376///     .start()                    -> ActrRef                 (running node)
377/// ```
378///
379/// Once you call `attach`, you no longer have a `Hyper`: you have a `Node`,
380/// which is "Hyper wired to a workload". `register` and `start` live on
381/// `Node`, not on `Hyper`.
382pub struct Hyper {
383    inner: Arc<HyperInner>,
384}
385
386#[cfg(not(target_arch = "wasm32"))]
387struct HyperInner {
388    config: HyperConfig,
389    /// Locally unique ID generated and persisted on first startup
390    instance_id: String,
391    /// Optional platform provider for cross-platform abstraction
392    platform: Option<Arc<dyn PlatformProvider>>,
393}
394
395#[cfg(not(target_arch = "wasm32"))]
396/// Carries state-dependent data for an attached [`Node`].
397struct Attachment {
398    node: crate::lifecycle::node::Inner,
399    /// Verified package retained for AIS bootstrap: the manifest plus the raw
400    /// manifest bytes and signature that AIS may need to re-verify upstream.
401    ///
402    /// `None` for linked attachments, which have no verified package
403    /// metadata attached. In that case `Node::register*` falls back to the
404    /// runtime config's actor metadata instead of package-derived
405    /// registration inputs.
406    verified: Option<VerifiedPackage>,
407    package_bytes: bytes::Bytes,
408}
409
410#[cfg(not(target_arch = "wasm32"))]
411/// Node — Hyper wired to a runtime configuration (and optionally a workload).
412///
413/// A `Node<Init>` is produced by [`Node::from_config_file`] or
414/// [`Node::from_hyper`]; it carries `Hyper` + [`actr_config::RuntimeConfig`]
415/// but has not yet been attached. Call one of the attach methods to progress
416/// into `Node<Attached>`, then `register().start()` into a running
417/// [`ActrRef`]:
418///
419/// ```text
420/// Node::from_config_file(path) -> Node<Init>
421///     .attach(package)         -> Node<Attached>   (attach: wasm / dyn lib)
422///     .link(workload)          -> Node<Attached>   (link: static lib)
423///
424/// Node<Attached>.register(ais) -> Node<Registered>
425/// Node<Registered>.start()     -> ActrRef
426/// ```
427///
428/// The default type parameter `Attached` means writing `Node` unqualified
429/// refers to the attached state; `start()` only exists on `Node<Registered>`.
430pub struct Node<S: NodeState = Attached> {
431    hyper: Arc<HyperInner>,
432    /// Present on `Node<Attached>` and `Node<Registered>`; `None` on
433    /// `Node<Init>`, which holds `pending_runtime_config` instead.
434    attachment: Option<Attachment>,
435    /// Pending runtime configuration for `Node<Init>`; consumed by attach
436    /// methods. `None` on `Attached` / `Registered`.
437    pending_runtime_config: Option<actr_config::RuntimeConfig>,
438    _state: std::marker::PhantomData<S>,
439}
440
441#[cfg(not(target_arch = "wasm32"))]
442/// Execution backend selected from a verified `.actr` package target.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub enum BinaryKind {
445    /// Execute the package binary with the WASM runtime.
446    Wasm,
447    /// Execute the package binary as a C-ABI dynamic library (`cdylib`).
448    DynClib,
449}
450
451#[cfg(not(target_arch = "wasm32"))]
452/// Public `.actr` package input object consumed by Hyper.
453#[derive(Debug, Clone)]
454pub struct WorkloadPackage {
455    bytes: bytes::Bytes,
456}
457
458#[cfg(not(target_arch = "wasm32"))]
459impl WorkloadPackage {
460    /// Wrap already-loaded package bytes.
461    pub fn new(bytes: impl Into<bytes::Bytes>) -> Self {
462        Self {
463            bytes: bytes.into(),
464        }
465    }
466
467    /// Load a `.actr` package from the filesystem in one call.
468    pub fn from_path(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
469        let bytes = std::fs::read(path)?;
470        Ok(Self {
471            bytes: bytes.into(),
472        })
473    }
474
475    /// Raw `.actr` bytes.
476    pub fn bytes(&self) -> &[u8] {
477        &self.bytes
478    }
479
480    /// Parse and return the package manifest (unverified).
481    ///
482    /// This reads the manifest TOML embedded in the `.actr` ZIP without checking
483    /// the signature. Use [`Hyper::verify_package`] to obtain a verified manifest.
484    /// Re-parses on every call — cache externally if you need it hot.
485    pub fn manifest(&self) -> HyperResult<actr_pack::PackageManifest> {
486        actr_pack::read_manifest(&self.bytes)
487            .map_err(|e| HyperError::InvalidManifest(e.to_string()))
488    }
489}
490
491#[cfg(not(target_arch = "wasm32"))]
492/// Result of verifying a package and preparing a runtime workload from it.
493pub(crate) struct LoadedWorkload {
494    /// Verified package retained for downstream bootstrap and storage
495    /// operations — carries the parsed manifest plus the raw manifest bytes
496    /// and signature needed for transparent forwarding to AIS.
497    pub verified: VerifiedPackage,
498    /// Binary kind detected from `verified.manifest.binary.target`.
499    pub binary_kind: BinaryKind,
500    /// Ready-to-attach runtime workload.
501    pub workload: crate::workload::Workload,
502}
503
504#[cfg(not(target_arch = "wasm32"))]
505impl std::fmt::Debug for LoadedWorkload {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("LoadedWorkload")
508            .field("manifest", &self.verified.manifest)
509            .field("backend", &self.binary_kind)
510            .finish_non_exhaustive()
511    }
512}
513
514#[cfg(not(target_arch = "wasm32"))]
515impl Hyper {
516    /// Construct a Hyper with native defaults (uses `tokio::fs` / `ActorStore`).
517    ///
518    /// - Parse configuration
519    /// - Load or generate instance_id (persisted to data_dir)
520    /// - Initialize package verifier
521    pub async fn new(config: HyperConfig) -> HyperResult<Self> {
522        Self::init_inner(config, None).await
523    }
524
525    /// Construct a Hyper with an injected platform provider (cross-platform / embedded).
526    ///
527    /// When a `PlatformProvider` is injected:
528    /// - instance UID comes from `platform.instance_uid()` (and its backing store)
529    /// - `bootstrap_credential` uses `platform.secret_store()` instead of `ActorStore::open()`
530    /// - `TrustProvider` verifies `.actr` package signatures using whatever
531    ///   mechanism the injected provider implements
532    pub async fn with_platform(
533        config: HyperConfig,
534        platform: Arc<dyn PlatformProvider>,
535    ) -> HyperResult<Self> {
536        Self::init_inner(config, Some(platform)).await
537    }
538
539    async fn init_inner(
540        config: HyperConfig,
541        platform: Option<Arc<dyn PlatformProvider>>,
542    ) -> HyperResult<Self> {
543        info!(
544            data_dir = %config.data_dir.display(),
545            "Hyper initializing"
546        );
547
548        // Resolve an instance UID + ensure data_dir exists. When a platform
549        // provider is injected, delegate to it; otherwise fall back to direct
550        // tokio::fs calls so this crate stays free of an actr-platform-native
551        // dependency (which would be circular).
552        let instance_id = if let Some(ref p) = platform {
553            p.instance_uid()
554                .await
555                .map_err(|e| HyperError::Storage(format!("failed to load instance_uid: {e}")))?
556        } else {
557            tokio::fs::create_dir_all(&config.data_dir)
558                .await
559                .map_err(|e| {
560                    HyperError::Config(format!(
561                        "failed to create data_dir `{}`: {e}",
562                        config.data_dir.display()
563                    ))
564                })?;
565            load_or_create_instance_uid_local(&config.data_dir).await?
566        };
567        debug!(instance_id, "Hyper instance_uid ready");
568
569        Ok(Self {
570            inner: Arc::new(HyperInner {
571                config,
572                instance_id,
573                platform,
574            }),
575        })
576    }
577
578    /// Verify a [`WorkloadPackage`] and return the verified package bundle
579    /// (parsed manifest + raw manifest bytes + signature).
580    ///
581    /// Delegates entirely to the configured [`crate::verify::TrustProvider`];
582    /// the provider decides how to authenticate the package (static key,
583    /// registry lookup, keyless transparency log, etc).
584    pub async fn verify_package(&self, package: &WorkloadPackage) -> HyperResult<VerifiedPackage> {
585        self.inner
586            .config
587            .trust_provider
588            .verify_package(package.bytes())
589            .await
590    }
591
592    /// Verify a package, select the execution backend from `binary.target`,
593    /// and prepare a runtime workload from it.
594    ///
595    /// Internal helper used by attachment flow and test-support shims.
596    #[cfg(feature = "test-utils")]
597    pub(crate) async fn load_workload_package(
598        &self,
599        package: &WorkloadPackage,
600    ) -> HyperResult<LoadedWorkload> {
601        load_workload_package_inner(&self.inner, package).await
602    }
603}
604
605// ── Node entry methods (unparameterized) ─────────────────────────────────────
606
607#[cfg(not(target_arch = "wasm32"))]
608impl Node {
609    /// Load `actr.toml` from disk, build the underlying [`Hyper`] from the
610    /// `[hyper]` section (or an explicit `[[trust]]` / `[hyper.trust]`
611    /// anchor set), and return a [`Node<Init>`] ready to attach a workload.
612    ///
613    /// The caller is expected to drive the typestate chain themselves:
614    ///
615    /// ```ignore
616    /// let actr_ref = Node::from_config_file("actr.toml").await?
617    ///     .attach(&package).await?
618    ///     .register(&ais_endpoint).await?
619    ///     .start().await?;
620    /// ```
621    ///
622    /// For a one-shot sugar covering the entire chain see
623    /// [`Node::run_from_config`].
624    pub async fn from_config_file(path: impl AsRef<std::path::Path>) -> HyperResult<Node<Init>> {
625        config::node_from_config_file(path.as_ref()).await
626    }
627
628    /// Like [`Node::from_config_file`] but lets the caller pin the
629    /// node's identity by supplying the [`actr_config::PackageInfo`]
630    /// derived from a sibling `manifest.toml`.
631    ///
632    /// This is the path the language bindings use: they already parsed
633    /// `manifest.toml` to find the runtime config dir and want their
634    /// `[package]` honoured rather than collapsed to the
635    /// `local:Client:0.0.0` placeholder that `from_config_file` synthesises.
636    pub async fn from_config_with_package(
637        path: impl AsRef<std::path::Path>,
638        package_info: actr_config::PackageInfo,
639    ) -> HyperResult<Node<Init>> {
640        config::node_from_config_file_with_package(path.as_ref(), Some(package_info)).await
641    }
642
643    /// Escape-hatch constructor: wrap an already-built [`Hyper`] plus a
644    /// pre-loaded [`actr_config::RuntimeConfig`] into a [`Node<Init>`].
645    ///
646    /// Use this when you need direct control over `HyperConfig`
647    /// construction (custom trust chain, injected platform provider, etc.)
648    /// and cannot drive the whole flow through
649    /// [`Node::from_config_file`].
650    pub fn from_hyper(hyper: Hyper, runtime_config: actr_config::RuntimeConfig) -> Node<Init> {
651        Node {
652            hyper: hyper.inner,
653            attachment: None,
654            pending_runtime_config: Some(runtime_config),
655            _state: std::marker::PhantomData,
656        }
657    }
658
659    /// One-shot sugar: `from_config_file(path).attach(package).register().start()`.
660    ///
661    /// Loads the runtime configuration from `path`, attaches the given
662    /// workload package, registers with AIS at the `[ais_endpoint]` URL
663    /// from the config, and starts the node, returning a live
664    /// [`ActrRef`]. Use the typestate chain directly when you need to
665    /// interleave `create_network_event_handle` or swap in a custom
666    /// `service_spec` via `register_with`.
667    pub async fn run_from_config(
668        path: impl AsRef<std::path::Path>,
669        package: &WorkloadPackage,
670    ) -> HyperResult<ActrRef> {
671        let init = Self::from_config_file(path).await?;
672        let ais_endpoint = init
673            .pending_runtime_config
674            .as_ref()
675            .map(|c| c.ais_endpoint.clone())
676            .expect("Node<Init> without pending runtime config");
677        let attached = init.attach(package).await?;
678        let registered = attached.register(&ais_endpoint).await?;
679        registered
680            .start()
681            .await
682            .map_err(|e| HyperError::Runtime(format!("failed to start node: {e}")))
683    }
684}
685
686// ── Node<Init> accessors + state transition: Init → Attached ─────────────────
687
688#[cfg(not(target_arch = "wasm32"))]
689impl Node<Init> {
690    /// Read-only view of the runtime configuration pending attachment.
691    /// Useful for callers that need to configure observability /
692    /// tracing subscribers from the config before driving `attach`.
693    pub fn runtime_config(&self) -> &actr_config::RuntimeConfig {
694        self.pending_runtime_config
695            .as_ref()
696            .expect("Node<Init> without pending runtime config")
697    }
698
699    /// Override the runtime actor type before attaching or linking a workload.
700    ///
701    /// `Node::from_config_file` can synthesize a placeholder actor type when
702    /// the runtime config has no package manifest. Linked/static hosts use this
703    /// method to provide the concrete actor identity used for AIS registration.
704    pub fn with_actor_type(mut self, actor_type: actr_protocol::ActrType) -> Self {
705        let runtime_config = self
706            .pending_runtime_config
707            .as_mut()
708            .expect("Node<Init> without pending runtime config");
709        runtime_config.package.name = actor_type.name.clone();
710        runtime_config.package.actr_type = actor_type;
711        self
712    }
713}
714
715#[cfg(not(target_arch = "wasm32"))]
716impl Node<Init> {
717    /// Bind a verified [`WorkloadPackage`] to this node.
718    ///
719    /// Equivalent to the former `Hyper::attach` — verifies the package
720    /// signature through the configured `TrustProvider`, loads its guest
721    /// binary (WASM or dynclib), and advances the node to
722    /// `Node<Attached>`.
723    /// Attach a packaged workload (`wasm` / `dyn lib`) to this node.
724    pub async fn attach(self, package: &WorkloadPackage) -> HyperResult<Node<Attached>> {
725        let runtime_config = self
726            .pending_runtime_config
727            .expect("Node<Init> without pending runtime config");
728        let hyper_inner = self.hyper;
729        let loaded = load_workload_package_inner(&hyper_inner, package).await?;
730        let packaged_lock = actr_pack::read_lock_file(package.bytes())
731            .map_err(|e| HyperError::Runtime(e.to_string()))?
732            .map(|bytes| {
733                let raw = std::str::from_utf8(&bytes).map_err(|e| {
734                    HyperError::Runtime(format!("manifest.lock.toml is not valid UTF-8: {e}"))
735                })?;
736                actr_config::lock::LockFile::from_str(raw).map_err(|e| {
737                    HyperError::Runtime(format!("failed to parse manifest.lock.toml: {e}"))
738                })
739            })
740            .transpose()?;
741        let mailbox_backpressure_threshold =
742            hyper_inner.config.resolved_mailbox_backpressure_threshold();
743        let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
744        let mut node_inner = crate::lifecycle::node::Inner::build(
745            runtime_config,
746            loaded.workload,
747            Some(loaded.verified.manifest.clone()),
748            packaged_lock,
749            mailbox_backpressure_threshold,
750            credential_expiry_warning,
751        )
752        .await
753        .map_err(|e| HyperError::Runtime(e.to_string()))?;
754        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
755            Arc::new(crate::workload::PackageHookObserver {
756                workload_dispatch: node_inner.workload_dispatch.clone(),
757            });
758        node_inner.hook_observer = Some(observer);
759        Ok(Node {
760            hyper: hyper_inner,
761            attachment: Some(Attachment {
762                node: node_inner,
763                verified: Some(loaded.verified),
764                package_bytes: package.bytes.clone(),
765            }),
766            pending_runtime_config: None,
767            _state: std::marker::PhantomData,
768        })
769    }
770
771    /// Bind an internal workload handle for the `link` path to this node.
772    ///
773    /// No package is loaded; the host process *is* the workload. The
774    /// handle provides both observation hooks and an inbound-dispatch
775    /// entry point (see [`workload::LinkedWorkloadHandle::dispatch`]).
776    ///
777    /// Prefer [`Node::link`] when you already have a generic
778    /// [`actr_framework::Workload`] implementation — it wraps the
779    /// workload in a [`workload::WorkloadAdapter`] automatically.
780    pub(crate) async fn link_handle(
781        self,
782        handle: Arc<dyn workload::LinkedWorkloadHandle>,
783    ) -> HyperResult<Node<Attached>> {
784        let runtime_config = self
785            .pending_runtime_config
786            .expect("Node<Init> without pending runtime config");
787        let hyper_inner = self.hyper;
788        let mailbox_backpressure_threshold =
789            hyper_inner.config.resolved_mailbox_backpressure_threshold();
790        let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
791        let mut node_inner = crate::lifecycle::node::Inner::build(
792            runtime_config,
793            crate::workload::Workload::Linked(handle.clone()),
794            None,
795            None,
796            mailbox_backpressure_threshold,
797            credential_expiry_warning,
798        )
799        .await
800        .map_err(|e| HyperError::Runtime(e.to_string()))?;
801        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
802            Arc::new(crate::workload::LinkedHandleObserver { handle });
803        node_inner.hook_observer = Some(observer);
804        Ok(Node {
805            hyper: hyper_inner,
806            attachment: Some(Attachment {
807                node: node_inner,
808                verified: None,
809                package_bytes: bytes::Bytes::new(),
810            }),
811            pending_runtime_config: None,
812            _state: std::marker::PhantomData,
813        })
814    }
815
816    /// Link a generic [`actr_framework::Workload`] implementation
817    /// (`static lib`) into this node.
818    ///
819    /// This is the preferred `link` path for Rust hosts: the
820    /// workload is wrapped in a [`workload::WorkloadAdapter`] so that its
821    /// associated [`actr_framework::MessageDispatcher`] drives inbound
822    /// RPC dispatch and its hook methods are bridged into the node's
823    /// observer plumbing.
824    pub async fn link<W: actr_framework::Workload>(
825        self,
826        workload: W,
827    ) -> HyperResult<Node<Attached>> {
828        let handle: Arc<dyn workload::LinkedWorkloadHandle> =
829            workload::WorkloadAdapter::new(workload);
830        self.link_handle(handle).await
831    }
832}
833
834// ── State transition: Attached → Registered ──────────────────────────────────
835
836#[cfg(not(target_arch = "wasm32"))]
837impl Node<Attached> {
838    /// Add a host-side observer to an already attached node.
839    ///
840    /// Package-backed nodes keep their guest hook observer installed; this
841    /// method chains the supplied host observer after it so shells such as
842    /// mobile bindings can watch signaling/WebRTC readiness without replacing
843    /// package-delivered hooks.
844    pub fn with_hook_observer<W: actr_framework::Workload>(mut self, observer: W) -> Self {
845        let attachment = self
846            .attachment
847            .as_mut()
848            .expect("Node<Attached> without attachment");
849        let handle: Arc<dyn workload::LinkedWorkloadHandle> =
850            workload::WorkloadAdapter::new(observer);
851        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
852            Arc::new(crate::workload::LinkedHandleObserver { handle });
853        attachment.node.hook_observer = crate::lifecycle::hooks::chain_observers(
854            attachment.node.hook_observer.take(),
855            Some(observer),
856        );
857        self
858    }
859
860    /// Register with AIS, obtain an AId credential, and inject it into this
861    /// attached node. Consumes `Node<Attached>` and returns `Node<Registered>`.
862    ///
863    /// `realm_id`, `acl`, and `realm_secret` come from the attached
864    /// [`RuntimeConfig`]; `service_spec` is derived from the package's proto
865    /// exports when a package-backed attach was used. Linked attachments
866    /// register from the runtime config's actor metadata instead.
867    pub async fn register(self, ais_endpoint: &str) -> HyperResult<Node<Registered>> {
868        let attachment = self
869            .attachment
870            .as_ref()
871            .expect("Node<Attached> without attachment");
872        let service_spec = if let Some(verified) = attachment.verified.as_ref() {
873            crate::service_spec::calculate_service_spec_from_package(
874                &attachment.package_bytes,
875                &verified.manifest,
876            )?
877        } else {
878            None
879        };
880        self.register_with(ais_endpoint, service_spec).await
881    }
882
883    /// Register with AIS using an explicit `service_spec`.
884    ///
885    /// This skips package-based `service_spec` derivation for
886    /// package-backed attachments. Linked attachments use the supplied
887    /// `service_spec` together with the runtime config's actor metadata.
888    pub async fn register_with(
889        mut self,
890        ais_endpoint: &str,
891        service_spec: Option<ServiceSpec>,
892    ) -> HyperResult<Node<Registered>> {
893        let attachment = self
894            .attachment
895            .as_mut()
896            .expect("Node<Attached> without attachment");
897        let realm_id = attachment.node.config.realm.realm_id;
898        let acl = attachment.node.config.acl.clone();
899        let realm_secret = attachment.node.config.realm_secret.clone();
900
901        let register_ok = if let Some(verified) = attachment.verified.as_ref() {
902            let verified = verified.clone();
903            bootstrap_credential_inner(
904                &self.hyper,
905                &verified,
906                ais_endpoint,
907                realm_id,
908                service_spec,
909                acl,
910                realm_secret.as_deref(),
911            )
912            .await?
913        } else {
914            bootstrap_linked_credential_inner(&attachment.node.config, ais_endpoint, service_spec)
915                .await?
916        };
917
918        attachment.node.set_preregistered_credential(register_ok);
919
920        Ok(Node {
921            hyper: self.hyper,
922            attachment: self.attachment,
923            pending_runtime_config: None,
924            _state: std::marker::PhantomData,
925        })
926    }
927
928    /// Create a network event handle for platform callbacks. Must be called
929    /// before [`Node::start`].
930    pub fn create_network_event_handle(
931        &mut self,
932        debounce_ms: u64,
933    ) -> crate::lifecycle::NetworkEventHandle {
934        self.attachment
935            .as_mut()
936            .expect("Node<Attached> without attachment")
937            .node
938            .create_network_event_handle(debounce_ms)
939    }
940
941    /// AIS endpoint URL resolved from the attached [`RuntimeConfig`].
942    /// Convenience accessor for callers that just drove `from_config_file`
943    /// + `attach` and need the endpoint to pass into `register`.
944    pub fn ais_endpoint(&self) -> &str {
945        &self
946            .attachment
947            .as_ref()
948            .expect("Node<Attached> without attachment")
949            .node
950            .config
951            .ais_endpoint
952    }
953}
954
955// ── State transition: Registered → ActrRef ───────────────────────────────────
956
957#[cfg(not(target_arch = "wasm32"))]
958impl Node<Registered> {
959    /// Start the attached, registered node and return the live [`ActrRef`].
960    pub async fn start(self) -> actr_protocol::ActorResult<crate::actr_ref::ActrRef> {
961        let Attachment { node, .. } = self
962            .attachment
963            .expect("Node<Registered> without attachment");
964        node.start().await
965    }
966
967    /// Create a network event handle for platform callbacks. Must be called
968    /// before [`Node::start`].
969    pub fn create_network_event_handle(
970        &mut self,
971        debounce_ms: u64,
972    ) -> crate::lifecycle::NetworkEventHandle {
973        self.attachment
974            .as_mut()
975            .expect("Node<Registered> without attachment")
976            .node
977            .create_network_event_handle(debounce_ms)
978    }
979}
980
981// ── Helpers available in all states ──────────────────────────────────────────
982
983// ── Hyper common helpers (framework operations that don't require attachment) ─
984
985#[cfg(not(target_arch = "wasm32"))]
986impl Hyper {
987    /// Resolve the storage namespace path for a verified manifest.
988    ///
989    /// The path is fixed here; all subsequent storage operations are isolated based on this path.
990    pub fn resolve_storage_path(&self, manifest: &PackageManifest) -> HyperResult<PathBuf> {
991        resolve_storage_path_for(&self.inner, manifest)
992    }
993
994    /// Bootstrap credential registration with AIS (two-phase flow).
995    ///
996    /// Hyper completes registration bootstrap on behalf of the Actor and returns the full AIS
997    /// registration payload.
998    ///
999    /// ## Two-Phase Logic
1000    ///
1001    /// - **Phase 1 (first registration)**: no valid PSK in ActorStore ->
1002    ///   register with MFR-signed manifest -> AIS returns credential + PSK -> stored in ActorStore
1003    /// - **Phase 2 (PSK renewal)**: valid PSK exists in ActorStore ->
1004    ///   register directly with PSK -> AIS returns new credential
1005    ///
1006    /// ## Parameters
1007    ///
1008    /// - `verified`: verified package bundle (from `verify_package`) — carries
1009    ///   the parsed manifest plus the raw manifest bytes and signature needed
1010    ///   for phase-1 registration with AIS.
1011    /// - `ais_endpoint`: AIS HTTP address, e.g. `"http://ais.example.com:8080"`
1012    /// - `realm_id`: target Realm ID
1013    /// - `service_spec`: optional protobuf API metadata published to discovery
1014    /// - `acl`: optional access-control policy attached to the actor
1015    pub async fn bootstrap_credential(
1016        &self,
1017        verified: &VerifiedPackage,
1018        ais_endpoint: &str,
1019        realm_id: u32,
1020        service_spec: Option<ServiceSpec>,
1021        acl: Option<Acl>,
1022    ) -> HyperResult<register_response::RegisterOk> {
1023        bootstrap_credential_inner(
1024            &self.inner,
1025            verified,
1026            ais_endpoint,
1027            realm_id,
1028            service_spec,
1029            acl,
1030            None,
1031        )
1032        .await
1033    }
1034
1035    /// Current instance_id
1036    pub fn instance_id(&self) -> &str {
1037        &self.inner.instance_id
1038    }
1039
1040    /// Current configuration
1041    pub fn config(&self) -> &HyperConfig {
1042        &self.inner.config
1043    }
1044}
1045
1046#[cfg(not(target_arch = "wasm32"))]
1047fn resolve_storage_path_for(
1048    inner: &HyperInner,
1049    manifest: &PackageManifest,
1050) -> HyperResult<PathBuf> {
1051    let resolver = config::NamespaceResolver::new(&inner.config, &inner.instance_id)?
1052        .with_actor_type(&manifest.manufacturer, &manifest.name, &manifest.version);
1053    resolver.resolve(&inner.config.storage_path_template)
1054}
1055
1056/// Free-function counterpart of [`Hyper::load_workload_package`] —
1057/// shared by both [`Hyper::attach`] and `Node<Init>::attach` without
1058/// needing a `Hyper` handle to own the call.
1059#[cfg(not(target_arch = "wasm32"))]
1060pub(crate) async fn load_workload_package_inner(
1061    inner: &HyperInner,
1062    package: &WorkloadPackage,
1063) -> HyperResult<LoadedWorkload> {
1064    let bytes = package.bytes();
1065    let verified = inner.config.trust_provider.verify_package(bytes).await?;
1066    let binary_kind = detect_binary_kind(&verified.manifest)?;
1067    let workload = match binary_kind {
1068        BinaryKind::Wasm => load_wasm_workload_inner(inner, bytes, &verified.manifest).await?,
1069        BinaryKind::DynClib => load_dynclib_workload_inner(inner, bytes, &verified.manifest)?,
1070    };
1071    Ok(LoadedWorkload {
1072        verified,
1073        binary_kind,
1074        workload,
1075    })
1076}
1077
1078#[cfg(not(target_arch = "wasm32"))]
1079async fn load_wasm_workload_inner(
1080    _inner: &HyperInner,
1081    bytes: &[u8],
1082    manifest: &PackageManifest,
1083) -> HyperResult<crate::workload::Workload> {
1084    #[cfg(feature = "wasm-engine")]
1085    {
1086        // Refuse legacy core-module packages before attempting to compile
1087        // them — `Component::from_binary` already rejects them downstream
1088        // with an opaque "unknown binary format" error, so catching the
1089        // case here produces a migration-pointing message instead.
1090        if matches!(
1091            manifest.binary.resolved_kind(),
1092            actr_pack::BinaryKind::CoreModule
1093        ) {
1094            return Err(HyperError::InvalidManifest(format!(
1095                "package `{}` uses the legacy core wasm module format, which was retired in Phase 1. \
1096                 Rebuild with actr 0.2+ (`actr build`, target wasm32-wasip2 + wasm-component-ld 0.5.22+) \
1097                 to produce a Component Model binary, and set `binary.kind = \"component\"` in manifest.toml.",
1098                manifest.actr_type_str()
1099            )));
1100        }
1101
1102        let wasm_bytes = actr_pack::load_binary(bytes).map_err(|e| {
1103            HyperError::Runtime(format!(
1104                "failed to extract package binary `{}` for target `{}`: {e}",
1105                manifest.binary.path, manifest.binary.target
1106            ))
1107        })?;
1108        let host = crate::wasm::WasmHost::compile(&wasm_bytes).map_err(|e| {
1109            HyperError::Runtime(format!(
1110                "failed to compile WASM package target `{}`: {e}",
1111                manifest.binary.target
1112            ))
1113        })?;
1114        let mut instance = host.instantiate().await.map_err(|e| {
1115            HyperError::Runtime(format!(
1116                "failed to instantiate WASM package target `{}`: {e}",
1117                manifest.binary.target
1118            ))
1119        })?;
1120        instance
1121            .init(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
1122                version: actr_framework::guest::dynclib_abi::version::V1,
1123                actr_type: manifest.actr_type_str(),
1124                credential: Vec::new(),
1125                actor_id: Vec::new(),
1126                realm_id: 0,
1127            })
1128            .map_err(|e| {
1129                HyperError::Runtime(format!(
1130                    "failed to initialize WASM package target `{}`: {e}",
1131                    manifest.binary.target
1132                ))
1133            })?;
1134        Ok(crate::workload::Workload::Wasm(instance))
1135    }
1136
1137    #[cfg(not(feature = "wasm-engine"))]
1138    {
1139        let _ = (bytes, manifest);
1140        Err(HyperError::Runtime(
1141            "package target requires the `wasm-engine` feature, but it is not enabled".to_string(),
1142        ))
1143    }
1144}
1145
1146#[cfg(not(target_arch = "wasm32"))]
1147fn load_dynclib_workload_inner(
1148    _inner: &HyperInner,
1149    bytes: &[u8],
1150    manifest: &PackageManifest,
1151) -> HyperResult<crate::workload::Workload> {
1152    #[cfg(feature = "dynclib-engine")]
1153    {
1154        let cache_path = ensure_dynclib_cache_path(&_inner.config.data_dir, bytes, manifest)?;
1155        let host = load_dynclib_host_with_rebuild(&cache_path, bytes, manifest)?;
1156        let instance = host
1157            .instantiate(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
1158                version: actr_framework::guest::dynclib_abi::version::V1,
1159                actr_type: manifest.actr_type_str(),
1160                credential: Vec::new(),
1161                actor_id: Vec::new(),
1162                realm_id: 0,
1163            })
1164            .map_err(|e| {
1165                HyperError::Runtime(format!(
1166                    "failed to initialize dynclib package target `{}`: {e}",
1167                    manifest.binary.target
1168                ))
1169            })?;
1170
1171        Ok(crate::workload::Workload::DynClib(
1172            crate::dynclib::DynClibWorkload::new(host, instance),
1173        ))
1174    }
1175
1176    #[cfg(not(feature = "dynclib-engine"))]
1177    {
1178        let _ = (bytes, manifest);
1179        Err(HyperError::Runtime(
1180            "package target requires the `dynclib-engine` feature, but it is not enabled"
1181                .to_string(),
1182        ))
1183    }
1184}
1185
1186#[cfg(not(target_arch = "wasm32"))]
1187async fn bootstrap_credential_inner(
1188    inner: &HyperInner,
1189    verified: &VerifiedPackage,
1190    ais_endpoint: &str,
1191    realm_id: u32,
1192    service_spec: Option<ServiceSpec>,
1193    acl: Option<Acl>,
1194    realm_secret: Option<&str>,
1195) -> HyperResult<register_response::RegisterOk> {
1196    let manifest = &verified.manifest;
1197    info!(
1198        actr_type = manifest.actr_type_str(),
1199        ais_endpoint, realm_id, "starting credential bootstrap with AIS"
1200    );
1201
1202    // 1. Open the Actor's secret store (via platform provider or direct ActorStore)
1203    let storage_path = resolve_storage_path_for(inner, manifest)?;
1204    let store: Arc<dyn KvStore> = if let Some(ref platform) = inner.platform {
1205        let ns = storage_path.to_string_lossy().to_string();
1206        platform
1207            .secret_store(&ns)
1208            .await
1209            .map_err(|e| HyperError::Storage(format!("failed to open secret store: {e}")))?
1210    } else {
1211        Arc::new(ActorStore::open(&storage_path).await?)
1212    };
1213
1214    // 2. Check if there is a valid PSK in ActorStore
1215    let valid_psk = load_valid_psk_dyn(&*store).await?;
1216
1217    // 3. Build RegisterRequest and send to AIS
1218    let mut ais = AisClient::new(ais_endpoint);
1219    if let Some(secret) = realm_secret {
1220        ais = ais.with_realm_secret(secret);
1221    }
1222
1223    let actr_type = ActrType {
1224        manufacturer: manifest.manufacturer.clone(),
1225        name: manifest.name.clone(),
1226        version: manifest.version.clone(),
1227    };
1228    let realm = Realm { realm_id };
1229
1230    let response = if let Some(psk_token) = valid_psk {
1231        // Phase 2: PSK renewal
1232        debug!(
1233            actr_type = manifest.actr_type_str(),
1234            "renewing credential using PSK"
1235        );
1236        let req = RegisterRequest {
1237            actr_type,
1238            realm,
1239            service_spec,
1240            acl,
1241            service: None,
1242            ws_address: None,
1243            manifest_raw: None,
1244            mfr_signature: None,
1245            psk_token: Some(psk_token.into()),
1246            target: Some(manifest.binary.target.clone()),
1247            auth_mode: Some(RegisterAuthMode::Package as i32),
1248        };
1249        ais.register_with_psk(req).await?
1250    } else {
1251        // Phase 1: first registration, carrying MFR manifest
1252        info!(
1253            actr_type = manifest.actr_type_str(),
1254            "first registration: registering with AIS using MFR manifest"
1255        );
1256
1257        let req = RegisterRequest {
1258            actr_type,
1259            realm,
1260            service_spec,
1261            acl,
1262            service: None,
1263            ws_address: None,
1264            manifest_raw: Some(verified.manifest_raw.clone().into()),
1265            mfr_signature: Some(verified.sig_raw.clone().into()),
1266            psk_token: None,
1267            target: Some(manifest.binary.target.clone()),
1268            auth_mode: Some(RegisterAuthMode::Package as i32),
1269        };
1270        ais.register_with_manifest(req).await?
1271    };
1272
1273    // 4. Process AIS response
1274    let ok = match response.result {
1275        Some(register_response::Result::Success(ok)) => ok,
1276        Some(register_response::Result::Error(e)) => {
1277            error!(
1278                actr_type = manifest.actr_type_str(),
1279                error_code = e.code,
1280                error_message = %e.message,
1281                "AIS registration returned error"
1282            );
1283            return Err(HyperError::AisBootstrapFailed(format!(
1284                "AIS rejected registration (code={}): {}",
1285                e.code, e.message
1286            )));
1287        }
1288        None => {
1289            error!(
1290                actr_type = manifest.actr_type_str(),
1291                "AIS response missing result field"
1292            );
1293            return Err(HyperError::AisBootstrapFailed(
1294                "AIS response missing result field".to_string(),
1295            ));
1296        }
1297    };
1298
1299    // 5a. If the response contains a PSK (first registration scenario), store it in ActorStore
1300    if let (Some(psk), Some(psk_expires_at)) = (&ok.psk, ok.psk_expires_at) {
1301        info!(
1302            actr_type = manifest.actr_type_str(),
1303            psk_expires_at, "received PSK from AIS, storing in ActorStore"
1304        );
1305        let expires_at_bytes = (psk_expires_at as u64).to_le_bytes().to_vec();
1306        store
1307            .batch(vec![
1308                KvOp::Set {
1309                    key: "hyper:psk:token".to_string(),
1310                    value: psk.to_vec(),
1311                },
1312                KvOp::Set {
1313                    key: "hyper:psk:expires_at".to_string(),
1314                    value: expires_at_bytes,
1315                },
1316            ])
1317            .await
1318            .map_err(|e| HyperError::Storage(format!("failed to store PSK: {e}")))?;
1319        debug!(
1320            actr_type = manifest.actr_type_str(),
1321            "PSK successfully persisted to ActorStore"
1322        );
1323    }
1324
1325    // 5b. Store signing_pubkey + signing_key_id (for AisKeyCache use)
1326    let pubkey_bytes = ok.signing_pubkey.to_vec();
1327    let key_id_bytes = ok.signing_key_id.to_le_bytes().to_vec();
1328    store
1329        .batch(vec![
1330            KvOp::Set {
1331                key: "hyper:ais:signing_pubkey".to_string(),
1332                value: pubkey_bytes,
1333            },
1334            KvOp::Set {
1335                key: "hyper:ais:signing_key_id".to_string(),
1336                value: key_id_bytes,
1337            },
1338        ])
1339        .await
1340        .map_err(|e| HyperError::Storage(format!("failed to store signing key: {e}")))?;
1341    debug!(
1342        actr_type = manifest.actr_type_str(),
1343        signing_key_id = ok.signing_key_id,
1344        "AIS signing public key persisted to ActorStore"
1345    );
1346
1347    info!(
1348        actr_type = manifest.actr_type_str(),
1349        credential_len = ok.credential.encode_to_vec().len(),
1350        "AIS credential bootstrap succeeded"
1351    );
1352
1353    Ok(ok)
1354}
1355
1356#[cfg(not(target_arch = "wasm32"))]
1357async fn bootstrap_linked_credential_inner(
1358    config: &actr_config::RuntimeConfig,
1359    ais_endpoint: &str,
1360    service_spec: Option<ServiceSpec>,
1361) -> HyperResult<register_response::RegisterOk> {
1362    let mut ais = AisClient::new(ais_endpoint);
1363    if let Some(ref secret) = config.realm_secret {
1364        ais = ais.with_realm_secret(secret.clone());
1365    }
1366
1367    let req = build_linked_register_request(config, service_spec);
1368    let response = ais.register_linked(req).await?;
1369    match response.result {
1370        Some(register_response::Result::Success(ok)) => Ok(ok),
1371        Some(register_response::Result::Error(e)) => Err(HyperError::AisBootstrapFailed(format!(
1372            "AIS rejected registration (code={}): {}",
1373            e.code, e.message
1374        ))),
1375        None => Err(HyperError::AisBootstrapFailed(
1376            "AIS response missing result field".to_string(),
1377        )),
1378    }
1379}
1380
1381#[cfg(not(target_arch = "wasm32"))]
1382fn build_linked_register_request(
1383    config: &actr_config::RuntimeConfig,
1384    service_spec: Option<ServiceSpec>,
1385) -> RegisterRequest {
1386    let ws_address = if let Some(port) = config.websocket_listen_port {
1387        let host = config
1388            .websocket_advertised_host
1389            .as_deref()
1390            .unwrap_or("127.0.0.1");
1391        Some(format!("ws://{}:{}", host, port))
1392    } else {
1393        None
1394    };
1395
1396    RegisterRequest {
1397        actr_type: config.actr_type().clone(),
1398        realm: config.realm,
1399        service_spec,
1400        acl: config.acl.clone(),
1401        service: None,
1402        ws_address,
1403        auth_mode: Some(RegisterAuthMode::Linked as i32),
1404        ..Default::default()
1405    }
1406}
1407
1408// ─── Helper functions (native-only) ──────────────────────────────────────────
1409
1410#[cfg(not(target_arch = "wasm32"))]
1411/// Load PSK from any KvStore implementation; returns PSK bytes if present and not expired
1412///
1413/// PSK expiration check: considered expired when current Unix timestamp (seconds) >= expires_at.
1414async fn load_valid_psk_dyn(store: &dyn KvStore) -> HyperResult<Option<Vec<u8>>> {
1415    let token = store
1416        .get("hyper:psk:token")
1417        .await
1418        .map_err(|e| HyperError::Storage(format!("failed to read PSK token: {e}")))?;
1419    let expires_at_raw = store
1420        .get("hyper:psk:expires_at")
1421        .await
1422        .map_err(|e| HyperError::Storage(format!("failed to read PSK expires_at: {e}")))?;
1423
1424    check_psk_expiry(token, expires_at_raw)
1425}
1426
1427/// Load PSK from ActorStore; returns PSK bytes if present and not expired, otherwise None
1428///
1429/// PSK expiration check: considered expired when current Unix timestamp (seconds) >= expires_at.
1430#[cfg(all(not(target_arch = "wasm32"), test))]
1431async fn load_valid_psk(store: &ActorStore) -> HyperResult<Option<Vec<u8>>> {
1432    let token = store.kv_get("hyper:psk:token").await?;
1433    let expires_at_raw = store.kv_get("hyper:psk:expires_at").await?;
1434
1435    check_psk_expiry(token, expires_at_raw)
1436}
1437
1438#[cfg(not(target_arch = "wasm32"))]
1439/// Check PSK expiry given pre-fetched token and expires_at values
1440fn check_psk_expiry(
1441    token: Option<Vec<u8>>,
1442    expires_at_raw: Option<Vec<u8>>,
1443) -> HyperResult<Option<Vec<u8>>> {
1444    match (token, expires_at_raw) {
1445        (Some(token), Some(expires_bytes)) => {
1446            // parse expiration time (u64 little-endian)
1447            if expires_bytes.len() != 8 {
1448                warn!("PSK expires_at has unexpected format, falling back to first registration");
1449                return Ok(None);
1450            }
1451            let expires_at = u64::from_le_bytes(expires_bytes.as_slice().try_into().unwrap());
1452
1453            // get current Unix timestamp (seconds)
1454            let now_secs = SystemTime::now()
1455                .duration_since(UNIX_EPOCH)
1456                .unwrap_or_default()
1457                .as_secs();
1458
1459            if now_secs >= expires_at {
1460                warn!(
1461                    psk_expires_at = expires_at,
1462                    now = now_secs,
1463                    "PSK expired, falling back to first registration"
1464                );
1465                Ok(None)
1466            } else {
1467                debug!(
1468                    psk_expires_at = expires_at,
1469                    now = now_secs,
1470                    remaining_secs = expires_at - now_secs,
1471                    "PSK valid, using PSK renewal path"
1472                );
1473                Ok(Some(token))
1474            }
1475        }
1476        _ => {
1477            debug!("no PSK in ActorStore, proceeding with first registration");
1478            Ok(None)
1479        }
1480    }
1481}
1482
1483#[cfg(not(target_arch = "wasm32"))]
1484#[cfg(not(target_arch = "wasm32"))]
1485fn detect_binary_kind(manifest: &PackageManifest) -> HyperResult<BinaryKind> {
1486    if manifest.binary.is_wasm_target() {
1487        return Ok(BinaryKind::Wasm);
1488    }
1489
1490    if is_compatible_native_target(&manifest.binary.target) {
1491        return Ok(BinaryKind::DynClib);
1492    }
1493
1494    Err(HyperError::InvalidManifest(format!(
1495        "unsupported binary target `{}` for host `{}-{}`; expected `wasm32-*` or a native target matching this host",
1496        manifest.binary.target,
1497        std::env::consts::ARCH,
1498        std::env::consts::OS,
1499    )))
1500}
1501
1502/// Check that `target` is a valid Rust target triple compatible with the current host.
1503///
1504/// A target triple has at least 3 segments (arch-vendor-os or arch-vendor-os-env).
1505/// We verify that the arch and OS components match the running host to reject
1506/// cross-platform cdylib packages early, rather than failing at `dlopen` time.
1507#[cfg(not(target_arch = "wasm32"))]
1508fn is_compatible_native_target(target: &str) -> bool {
1509    let segments: Vec<&str> = target.split('-').filter(|s| !s.is_empty()).collect();
1510    if segments.len() < 3 {
1511        return false;
1512    }
1513
1514    let target_arch = segments[0];
1515    // OS is typically the third segment (arch-vendor-os[-env]).
1516    let target_os = segments[2];
1517
1518    // Normalize arch names: Rust target triples use different names than std::env::consts::ARCH.
1519    let arch_matches = match (target_arch, std::env::consts::ARCH) {
1520        (a, b) if a == b => true,
1521        ("x86_64", "x86_64") => true,
1522        ("aarch64", "aarch64") => true,
1523        _ => false,
1524    };
1525
1526    // Normalize OS names: Rust target triples use e.g. "darwin" while consts::OS is "macos".
1527    let os_matches = match (target_os, std::env::consts::OS) {
1528        (a, b) if a == b => true,
1529        ("darwin", "macos") | ("macos", "darwin") => true,
1530        _ => false,
1531    };
1532
1533    arch_matches && os_matches
1534}
1535
1536#[cfg(all(
1537    not(target_arch = "wasm32"),
1538    feature = "dynclib-engine",
1539    target_os = "macos"
1540))]
1541fn dynclib_tempfile_suffix() -> &'static str {
1542    ".dylib"
1543}
1544
1545#[cfg(all(
1546    not(target_arch = "wasm32"),
1547    feature = "dynclib-engine",
1548    target_os = "linux"
1549))]
1550fn dynclib_tempfile_suffix() -> &'static str {
1551    ".so"
1552}
1553
1554#[cfg(all(
1555    not(target_arch = "wasm32"),
1556    feature = "dynclib-engine",
1557    target_os = "windows"
1558))]
1559fn dynclib_tempfile_suffix() -> &'static str {
1560    ".dll"
1561}
1562
1563#[cfg(all(
1564    not(target_arch = "wasm32"),
1565    feature = "dynclib-engine",
1566    not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
1567))]
1568fn dynclib_tempfile_suffix() -> &'static str {
1569    ".dynlib"
1570}
1571
1572#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1573const DYNCLIB_CACHE_DIR: &str = "dynclib-cache";
1574
1575#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1576fn dynclib_cache_dir(data_dir: &Path) -> PathBuf {
1577    data_dir.join(DYNCLIB_CACHE_DIR)
1578}
1579
1580#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1581fn dynclib_cache_path(data_dir: &Path, binary_hash: &[u8; 32]) -> PathBuf {
1582    dynclib_cache_dir(data_dir).join(format!(
1583        "{}{}",
1584        hex::encode(binary_hash),
1585        dynclib_tempfile_suffix()
1586    ))
1587}
1588
1589#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1590fn extract_dynclib_binary(bytes: &[u8], manifest: &PackageManifest) -> HyperResult<Vec<u8>> {
1591    actr_pack::load_binary(bytes).map_err(|e| {
1592        HyperError::Runtime(format!(
1593            "failed to extract package binary `{}` for target `{}`: {e}",
1594            manifest.binary.path, manifest.binary.target
1595        ))
1596    })
1597}
1598
1599#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1600fn write_dynclib_cache_file(cache_path: &Path, binary_bytes: &[u8]) -> HyperResult<()> {
1601    let cache_dir = cache_path.parent().ok_or_else(|| {
1602        HyperError::Runtime("dynclib cache path has no parent directory".to_string())
1603    })?;
1604    std::fs::create_dir_all(cache_dir).map_err(|e| {
1605        HyperError::Runtime(format!(
1606            "failed to create dynclib cache directory `{}`: {e}",
1607            cache_dir.display()
1608        ))
1609    })?;
1610
1611    let mut temp_file = tempfile::Builder::new()
1612        .prefix("actr-dynclib-")
1613        .tempfile_in(cache_dir)
1614        .map_err(|e| {
1615            HyperError::Runtime(format!(
1616                "failed to allocate dynclib cache temp file in `{}`: {e}",
1617                cache_dir.display()
1618            ))
1619        })?;
1620
1621    temp_file.write_all(binary_bytes).map_err(|e| {
1622        HyperError::Runtime(format!(
1623            "failed to write dynclib cache temp file `{}`: {e}",
1624            temp_file.path().display()
1625        ))
1626    })?;
1627    temp_file.flush().map_err(|e| {
1628        HyperError::Runtime(format!(
1629            "failed to flush dynclib cache temp file `{}`: {e}",
1630            temp_file.path().display()
1631        ))
1632    })?;
1633
1634    match temp_file.persist_noclobber(cache_path) {
1635        Ok(_) => Ok(()),
1636        Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
1637        Err(err) => Err(HyperError::Runtime(format!(
1638            "failed to persist dynclib cache file `{}`: {}",
1639            cache_path.display(),
1640            err.error
1641        ))),
1642    }
1643}
1644
1645#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1646fn ensure_dynclib_cache_path(
1647    data_dir: &Path,
1648    bytes: &[u8],
1649    manifest: &PackageManifest,
1650) -> HyperResult<PathBuf> {
1651    let binary_hash = manifest
1652        .binary
1653        .hash_bytes()
1654        .map_err(|e| HyperError::InvalidManifest(e.to_string()))?;
1655    let cache_path = dynclib_cache_path(data_dir, &binary_hash);
1656    if cache_path.exists() {
1657        return Ok(cache_path);
1658    }
1659
1660    let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
1661    write_dynclib_cache_file(&cache_path, &binary_bytes)?;
1662    Ok(cache_path)
1663}
1664
1665#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1666fn rebuild_dynclib_cache_file(
1667    cache_path: &Path,
1668    bytes: &[u8],
1669    manifest: &PackageManifest,
1670) -> HyperResult<()> {
1671    match std::fs::remove_file(cache_path) {
1672        Ok(()) => {}
1673        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1674        Err(err) => {
1675            return Err(HyperError::Runtime(format!(
1676                "failed to remove corrupt dynclib cache file `{}`: {err}",
1677                cache_path.display()
1678            )));
1679        }
1680    }
1681
1682    let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
1683    write_dynclib_cache_file(cache_path, &binary_bytes)
1684}
1685
1686#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1687fn load_dynclib_host_with_rebuild(
1688    cache_path: &Path,
1689    bytes: &[u8],
1690    manifest: &PackageManifest,
1691) -> HyperResult<crate::dynclib::DynclibHost> {
1692    match crate::dynclib::DynclibHost::load(cache_path) {
1693        Ok(host) => Ok(host),
1694        Err(first_err) => {
1695            warn!(
1696                path = %cache_path.display(),
1697                target = %manifest.binary.target,
1698                error = %first_err,
1699                "cached dynclib load failed, rebuilding cache once"
1700            );
1701            rebuild_dynclib_cache_file(cache_path, bytes, manifest)?;
1702            crate::dynclib::DynclibHost::load(cache_path).map_err(|second_err| {
1703                HyperError::Runtime(format!(
1704                    "failed to load dynclib package target `{}` from cache `{}` after rebuild; first load error: {first_err}; second load error: {second_err}",
1705                    manifest.binary.target,
1706                    cache_path.display()
1707                ))
1708            })
1709        }
1710    }
1711}
1712
1713#[cfg(not(target_arch = "wasm32"))]
1714/// Load an existing `instance_uid` or generate and persist a new one.
1715///
1716/// Used only when no `PlatformProvider` is injected; otherwise the provider's
1717/// `instance_uid()` is the source of truth.
1718async fn load_or_create_instance_uid_local(data_dir: &std::path::Path) -> HyperResult<String> {
1719    let id_file = data_dir.join(".hyper-instance-uid");
1720
1721    if id_file.exists() {
1722        let id = tokio::fs::read_to_string(&id_file)
1723            .await
1724            .map_err(|e| HyperError::Storage(format!("failed to read instance_uid file: {e}")))?;
1725        let id = id.trim().to_string();
1726        if !id.is_empty() {
1727            return Ok(id);
1728        }
1729        warn!("instance_uid file is empty; generating a new one");
1730    }
1731
1732    let new_id = Uuid::new_v4().to_string();
1733    tokio::fs::write(&id_file, &new_id)
1734        .await
1735        .map_err(|e| HyperError::Storage(format!("failed to write instance_uid file: {e}")))?;
1736    info!(instance_uid = %new_id, "generated a new Hyper instance_uid");
1737    Ok(new_id)
1738}
1739
1740#[cfg(all(not(target_arch = "wasm32"), test))]
1741mod tests {
1742    use super::*;
1743    use ed25519_dalek::SigningKey;
1744    use rand::rngs::OsRng;
1745    #[cfg(feature = "dynclib-engine")]
1746    use std::sync::{Arc, Barrier};
1747    use tempfile::TempDir;
1748
1749    fn dev_config(dir: &TempDir) -> HyperConfig {
1750        let signing_key = SigningKey::generate(&mut OsRng);
1751        let pubkey = signing_key.verifying_key().to_bytes();
1752        HyperConfig::new(
1753            dir.path(),
1754            Arc::new(crate::verify::StaticTrust::new(pubkey).unwrap()),
1755        )
1756    }
1757
1758    #[tokio::test]
1759    async fn init_creates_data_dir_and_instance_id() {
1760        let dir = TempDir::new().unwrap();
1761        let sub = dir.path().join("subdir/nested");
1762        let signing_key = SigningKey::generate(&mut OsRng);
1763        let config = HyperConfig::new(
1764            &sub,
1765            Arc::new(
1766                crate::verify::StaticTrust::new(signing_key.verifying_key().to_bytes()).unwrap(),
1767            ),
1768        );
1769
1770        let hyper = Hyper::new(config).await.unwrap();
1771        assert!(sub.exists());
1772        assert!(!hyper.instance_id().is_empty());
1773    }
1774
1775    #[tokio::test]
1776    async fn instance_id_is_stable_across_reinit() {
1777        let dir = TempDir::new().unwrap();
1778        let config1 = dev_config(&dir);
1779        let hyper1 = Hyper::new(config1).await.unwrap();
1780        let id1 = hyper1.instance_id().to_string();
1781
1782        let config2 = dev_config(&dir);
1783        let hyper2 = Hyper::new(config2).await.unwrap();
1784        let id2 = hyper2.instance_id().to_string();
1785
1786        assert_eq!(id1, id2, "instance_id should remain stable across restarts");
1787    }
1788
1789    #[tokio::test]
1790    async fn verify_package_rejects_non_wasm() {
1791        let dir = TempDir::new().unwrap();
1792        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
1793        let result = hyper
1794            .verify_package(&WorkloadPackage::new(b"not a wasm file".to_vec()))
1795            .await;
1796        assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
1797    }
1798
1799    #[tokio::test]
1800    async fn verify_package_rejects_non_actr_format() {
1801        let dir = TempDir::new().unwrap();
1802        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
1803
1804        // Non-.actr bytes should return InvalidManifest
1805        let result = hyper
1806            .verify_package(&WorkloadPackage::new(b"\0asm\x01\x00\x00\x00".to_vec()))
1807            .await;
1808        assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
1809    }
1810
1811    // ─── PSK storage and expiration unit tests ──────────────────────────────
1812
1813    async fn open_test_store(dir: &TempDir) -> ActorStore {
1814        let db_path = dir.path().join("test.db");
1815        ActorStore::open(&db_path).await.unwrap()
1816    }
1817
1818    /// Store a valid PSK and verify that load_valid_psk returns it.
1819    #[tokio::test]
1820    async fn psk_valid_returns_token() {
1821        let dir = TempDir::new().unwrap();
1822        let store = open_test_store(&dir).await;
1823
1824        let psk_token = b"test-psk-secret".to_vec();
1825        // Set the expiry time to one hour from now.
1826        let expires_at = SystemTime::now()
1827            .duration_since(UNIX_EPOCH)
1828            .unwrap()
1829            .as_secs()
1830            + 3600;
1831
1832        store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
1833        store
1834            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
1835            .await
1836            .unwrap();
1837
1838        let result = load_valid_psk(&store).await.unwrap();
1839        assert_eq!(result, Some(psk_token), "A valid PSK should be returned");
1840    }
1841
1842    /// Store an expired PSK and verify that load_valid_psk returns None.
1843    #[tokio::test]
1844    async fn psk_expired_returns_none() {
1845        let dir = TempDir::new().unwrap();
1846        let store = open_test_store(&dir).await;
1847
1848        let psk_token = b"expired-psk".to_vec();
1849        // Set the expiry time to one second in the past.
1850        let expires_at = SystemTime::now()
1851            .duration_since(UNIX_EPOCH)
1852            .unwrap()
1853            .as_secs()
1854            .saturating_sub(1);
1855
1856        store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
1857        store
1858            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
1859            .await
1860            .unwrap();
1861
1862        let result = load_valid_psk(&store).await.unwrap();
1863        assert_eq!(result, None, "An expired PSK should return None");
1864    }
1865
1866    /// load_valid_psk returns None when ActorStore has no PSK.
1867    #[tokio::test]
1868    async fn psk_absent_returns_none() {
1869        let dir = TempDir::new().unwrap();
1870        let store = open_test_store(&dir).await;
1871
1872        let result = load_valid_psk(&store).await.unwrap();
1873        assert_eq!(result, None, "Missing PSK should return None");
1874    }
1875
1876    /// load_valid_psk returns None if token exists without expires_at.
1877    #[tokio::test]
1878    async fn psk_missing_expires_at_returns_none() {
1879        let dir = TempDir::new().unwrap();
1880        let store = open_test_store(&dir).await;
1881
1882        store
1883            .kv_set("hyper:psk:token", b"orphan-token")
1884            .await
1885            .unwrap();
1886        // Intentionally leave expires_at unset.
1887
1888        let result = load_valid_psk(&store).await.unwrap();
1889        assert_eq!(result, None, "Missing expires_at should return None");
1890    }
1891
1892    // ─── AIS integration tests (mockito mock server) ────────────────────────
1893
1894    /// Helper: build a [`VerifiedPackage`] for tests.
1895    ///
1896    /// Uses the canonical `actr_pack::PackageManifest` shape wrapped with empty
1897    /// manifest_raw / sig_raw placeholders — bootstrap tests don't touch AIS's
1898    /// re-verification path, so those bytes are not inspected.
1899    fn fake_manifest() -> VerifiedPackage {
1900        VerifiedPackage {
1901            manifest: actr_pack::PackageManifest {
1902                manufacturer: "test-mfr".to_string(),
1903                name: "TestActor".to_string(),
1904                version: "0.1.0".to_string(),
1905                binary: actr_pack::BinaryEntry {
1906                    path: "bin/actor.wasm".to_string(),
1907                    target: "wasm32-wasip1".to_string(),
1908                    hash: "0".repeat(64),
1909                    size: None,
1910                    kind: None,
1911                },
1912                signature_algorithm: "ed25519".to_string(),
1913                signing_key_id: None,
1914                resources: vec![],
1915                proto_files: vec![],
1916                lock_file: None,
1917                metadata: actr_pack::ManifestMetadata::default(),
1918            },
1919            manifest_raw: vec![],
1920            sig_raw: vec![0u8; 64],
1921        }
1922    }
1923
1924    /// Helper: build valid RegisterResponse protobuf bytes with credential data.
1925    fn fake_register_response_bytes(with_psk: bool) -> Vec<u8> {
1926        use actr_protocol::{
1927            AIdCredential, ActrId, ActrType, IdentityClaims, Realm, RegisterResponse,
1928            TurnCredential, register_response,
1929        };
1930
1931        let claims = IdentityClaims {
1932            realm_id: 1,
1933            actor_id: "test-actor-id".to_string(),
1934            expires_at: u64::MAX,
1935        };
1936        let claims_bytes = claims.encode_to_vec();
1937
1938        let credential = AIdCredential {
1939            key_id: 1,
1940            claims: claims_bytes.into(),
1941            signature: vec![0u8; 64].into(),
1942        };
1943
1944        let actr_id = ActrId {
1945            realm: Realm { realm_id: 1 },
1946            serial_number: 42,
1947            r#type: ActrType {
1948                manufacturer: "test-mfr".to_string(),
1949                name: "TestActor".to_string(),
1950                version: "0.1.0".to_string(),
1951            },
1952        };
1953
1954        let turn = TurnCredential {
1955            username: "user".to_string(),
1956            password: "pass".to_string(),
1957            expires_at: u64::MAX,
1958        };
1959
1960        let mut ok = register_response::RegisterOk {
1961            actr_id,
1962            credential,
1963            turn_credential: turn,
1964            credential_expires_at: None,
1965            signaling_heartbeat_interval_secs: 30,
1966            signing_pubkey: vec![0u8; 32].into(),
1967            signing_key_id: 1,
1968            psk: None,
1969            psk_expires_at: None,
1970        };
1971
1972        if with_psk {
1973            ok.psk = Some(b"fresh-psk-from-ais".to_vec().into());
1974            ok.psk_expires_at = Some(
1975                (SystemTime::now()
1976                    .duration_since(UNIX_EPOCH)
1977                    .unwrap()
1978                    .as_secs()
1979                    + 86400) as i64,
1980            );
1981        }
1982
1983        RegisterResponse {
1984            result: Some(register_response::Result::Success(ok)),
1985        }
1986        .encode_to_vec()
1987    }
1988
1989    fn test_service_spec() -> Option<ServiceSpec> {
1990        Some(ServiceSpec {
1991            name: "EchoService".to_string(),
1992            description: Some("test service".to_string()),
1993            fingerprint: "fp-123".to_string(),
1994            protobufs: vec![],
1995            published_at: None,
1996            tags: vec!["latest".to_string()],
1997        })
1998    }
1999
2000    fn test_acl() -> Option<Acl> {
2001        Some(Acl { rules: vec![] })
2002    }
2003
2004    fn linked_runtime_config(dir: &TempDir) -> actr_config::RuntimeConfig {
2005        actr_config::RuntimeConfig {
2006            package: actr_config::PackageInfo {
2007                name: "LinkedActor".to_string(),
2008                actr_type: actr_protocol::ActrType {
2009                    manufacturer: "test-mfr".to_string(),
2010                    name: "LinkedActor".to_string(),
2011                    version: "0.1.0".to_string(),
2012                },
2013                description: None,
2014                authors: vec![],
2015                license: None,
2016            },
2017            signaling_url: url::Url::parse("ws://localhost:8081/signaling/ws").unwrap(),
2018            realm: Realm { realm_id: 7 },
2019            ais_endpoint: "http://localhost:8081/ais".to_string(),
2020            realm_secret: Some("test-realm-secret".to_string()),
2021            visible_in_discovery: true,
2022            acl: test_acl(),
2023            mailbox_path: None,
2024            scripts: std::collections::HashMap::new(),
2025            webrtc: actr_config::WebRtcConfig::default(),
2026            websocket_listen_port: Some(9100),
2027            websocket_advertised_host: Some("127.0.0.1".to_string()),
2028            observability: actr_config::ObservabilityConfig {
2029                filter_level: "info".to_string(),
2030                tracing_enabled: false,
2031                tracing_endpoint: "http://localhost:4317".to_string(),
2032                tracing_service_name: "linked-test".to_string(),
2033            },
2034            config_dir: dir.path().to_path_buf(),
2035            trust: vec![],
2036            package_path: None,
2037            web: None,
2038        }
2039    }
2040
2041    #[test]
2042    fn linked_register_request_uses_linked_auth_mode() {
2043        let dir = TempDir::new().unwrap();
2044        let req = build_linked_register_request(&linked_runtime_config(&dir), test_service_spec());
2045
2046        assert_eq!(req.auth_mode, Some(RegisterAuthMode::Linked as i32));
2047        assert_eq!(req.manifest_raw, None);
2048        assert_eq!(req.mfr_signature, None);
2049        assert_eq!(req.psk_token, None);
2050        assert_eq!(req.ws_address.as_deref(), Some("ws://127.0.0.1:9100"));
2051    }
2052
2053    #[tokio::test]
2054    async fn with_actor_type_overrides_pending_runtime_metadata() {
2055        let dir = TempDir::new().unwrap();
2056        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
2057        let node = Node::from_hyper(hyper, linked_runtime_config(&dir)).with_actor_type(
2058            actr_protocol::ActrType {
2059                manufacturer: "acme".into(),
2060                name: "UnifiedActor".into(),
2061                version: "1.0.0".into(),
2062            },
2063        );
2064
2065        let actr_type = node.runtime_config().actr_type();
2066        assert_eq!(actr_type.manufacturer, "acme");
2067        assert_eq!(actr_type.name, "UnifiedActor");
2068        assert_eq!(actr_type.version, "1.0.0");
2069    }
2070
2071    #[test]
2072    fn compatible_native_target_matches_current_host() {
2073        // Current host should always match itself.
2074        let current = format!(
2075            "{}-unknown-{}",
2076            std::env::consts::ARCH,
2077            if std::env::consts::OS == "macos" {
2078                "darwin"
2079            } else {
2080                std::env::consts::OS
2081            }
2082        );
2083        assert!(
2084            is_compatible_native_target(&current),
2085            "current host target `{current}` should be compatible"
2086        );
2087    }
2088
2089    #[test]
2090    fn compatible_native_target_rejects_cross_platform() {
2091        // A target for a different arch/os should be rejected.
2092        assert!(!is_compatible_native_target("riscv64gc-unknown-linux-gnu"));
2093        assert!(!is_compatible_native_target("s390x-unknown-linux-gnu"));
2094    }
2095
2096    #[test]
2097    fn compatible_native_target_rejects_short_triples() {
2098        assert!(!is_compatible_native_target("invalid-target"));
2099        assert!(!is_compatible_native_target("single"));
2100        assert!(!is_compatible_native_target(""));
2101    }
2102
2103    #[cfg(feature = "dynclib-engine")]
2104    fn fake_dynclib_manifest() -> PackageManifest {
2105        let target = format!(
2106            "{}-unknown-{}",
2107            std::env::consts::ARCH,
2108            if std::env::consts::OS == "macos" {
2109                "darwin"
2110            } else {
2111                std::env::consts::OS
2112            }
2113        );
2114        PackageManifest {
2115            manufacturer: "test-mfr".to_string(),
2116            name: "DynActor".to_string(),
2117            version: "1.0.0".to_string(),
2118            binary: actr_pack::BinaryEntry {
2119                path: format!("bin/actor{}", dynclib_tempfile_suffix()),
2120                target,
2121                hash: String::new(),
2122                size: None,
2123                kind: None,
2124            },
2125            signature_algorithm: "ed25519".to_string(),
2126            signing_key_id: None,
2127            resources: vec![],
2128            proto_files: vec![],
2129            lock_file: None,
2130            metadata: actr_pack::ManifestMetadata::default(),
2131        }
2132    }
2133
2134    #[cfg(feature = "dynclib-engine")]
2135    fn fake_dynclib_package_bytes(binary_bytes: &[u8]) -> (Vec<u8>, PackageManifest) {
2136        let manifest = fake_dynclib_manifest();
2137        let signing_key = SigningKey::generate(&mut OsRng);
2138        let package_bytes = actr_pack::pack(&actr_pack::PackOptions {
2139            manifest: manifest.clone(),
2140            binary_bytes: binary_bytes.to_vec(),
2141            resources: vec![],
2142            proto_files: vec![],
2143            lock_file: None,
2144            signing_key,
2145        })
2146        .unwrap();
2147        // `pack()` updates the embedded manifest's binary hash; re-parse so
2148        // the returned manifest agrees with what's actually in the archive.
2149        let packed_manifest = actr_pack::read_manifest(&package_bytes).unwrap();
2150        (package_bytes, packed_manifest)
2151    }
2152
2153    #[cfg(feature = "dynclib-engine")]
2154    #[test]
2155    fn dynclib_cache_path_uses_hash_and_platform_suffix() {
2156        let dir = TempDir::new().unwrap();
2157        let path = dynclib_cache_path(dir.path(), &[0xAB; 32]);
2158
2159        assert_eq!(path.parent().unwrap(), dynclib_cache_dir(dir.path()));
2160        assert_eq!(
2161            path.file_name().unwrap().to_string_lossy(),
2162            format!("{}{}", hex::encode([0xAB; 32]), dynclib_tempfile_suffix())
2163        );
2164    }
2165
2166    #[cfg(feature = "dynclib-engine")]
2167    #[test]
2168    fn ensure_dynclib_cache_path_preserves_existing_file() {
2169        let dir = TempDir::new().unwrap();
2170        let initial_binary_bytes = b"initial dylib bytes";
2171        let (initial_package_bytes, manifest) = fake_dynclib_package_bytes(initial_binary_bytes);
2172        let cache_path =
2173            ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
2174
2175        // Same initial binary -> same manifest.binary.hash -> same cache path;
2176        // a second call with a different binary under that hash cannot land
2177        // here, so re-run with the identical binary to assert idempotence.
2178        let second_path =
2179            ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
2180
2181        assert_eq!(cache_path, second_path);
2182        assert_eq!(std::fs::read(&cache_path).unwrap(), initial_binary_bytes);
2183    }
2184
2185    #[cfg(feature = "dynclib-engine")]
2186    #[test]
2187    fn ensure_dynclib_cache_path_handles_concurrent_creation() {
2188        let dir = TempDir::new().unwrap();
2189        let binary_bytes = b"shared dylib bytes".to_vec();
2190        let (package_bytes, manifest) = fake_dynclib_package_bytes(&binary_bytes);
2191        let package_bytes = Arc::new(package_bytes);
2192        let binary_bytes = Arc::new(binary_bytes);
2193        let data_dir = Arc::new(dir.path().to_path_buf());
2194        let barrier = Arc::new(Barrier::new(3));
2195
2196        let handles: Vec<_> = (0..2)
2197            .map(|_| {
2198                let barrier = Arc::clone(&barrier);
2199                let data_dir = Arc::clone(&data_dir);
2200                let manifest = manifest.clone();
2201                let package_bytes = Arc::clone(&package_bytes);
2202                std::thread::spawn(move || {
2203                    barrier.wait();
2204                    ensure_dynclib_cache_path(&data_dir, &package_bytes, &manifest)
2205                })
2206            })
2207            .collect();
2208
2209        barrier.wait();
2210
2211        let results: Vec<_> = handles
2212            .into_iter()
2213            .map(|handle| handle.join().unwrap().unwrap())
2214            .collect();
2215
2216        assert_eq!(results[0], results[1]);
2217        assert_eq!(
2218            std::fs::read(&results[0]).unwrap(),
2219            binary_bytes.as_ref().as_slice()
2220        );
2221    }
2222
2223    /// First registration with no PSK should store the PSK returned by AIS.
2224    #[tokio::test]
2225    async fn bootstrap_first_registration_stores_psk() {
2226        let response_body = fake_register_response_bytes(true);
2227
2228        let mut server = mockito::Server::new_async().await;
2229        let mock = server
2230            .mock("POST", "/register")
2231            .with_status(200)
2232            .with_header("content-type", "application/x-protobuf")
2233            .with_body(response_body)
2234            .create_async()
2235            .await;
2236
2237        let dir = TempDir::new().unwrap();
2238        let config = dev_config(&dir);
2239        let hyper = Hyper::new(config).await.unwrap();
2240
2241        let manifest = fake_manifest();
2242        let result = hyper
2243            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2244            .await;
2245
2246        mock.assert_async().await;
2247        assert!(
2248            result.is_ok(),
2249            "Initial registration should succeed, got: {:?}",
2250            result.err()
2251        );
2252
2253        // Verify the PSK was written to ActorStore.
2254        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2255        let store = ActorStore::open(&storage_path).await.unwrap();
2256        let psk = store.kv_get("hyper:psk:token").await.unwrap();
2257        assert!(
2258            psk.is_some(),
2259            "PSK should be stored in ActorStore after initial registration"
2260        );
2261        assert_eq!(psk.unwrap(), b"fresh-psk-from-ais".to_vec());
2262    }
2263
2264    /// A valid PSK should skip manifest registration and use the renewal path.
2265    #[tokio::test]
2266    async fn bootstrap_psk_renewal_skips_manifest() {
2267        let response_body = fake_register_response_bytes(false);
2268
2269        let mut server = mockito::Server::new_async().await;
2270        let mock = server
2271            .mock("POST", "/register")
2272            .with_status(200)
2273            .with_header("content-type", "application/x-protobuf")
2274            .with_body(response_body)
2275            .expect(1) // /register should be called exactly once.
2276            .create_async()
2277            .await;
2278
2279        let dir = TempDir::new().unwrap();
2280        let config = dev_config(&dir);
2281        let hyper = Hyper::new(config).await.unwrap();
2282
2283        // Seed ActorStore with a valid PSK.
2284        let manifest = fake_manifest();
2285        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2286        let store = ActorStore::open(&storage_path).await.unwrap();
2287
2288        let expires_at = SystemTime::now()
2289            .duration_since(UNIX_EPOCH)
2290            .unwrap()
2291            .as_secs()
2292            + 3600;
2293        store
2294            .kv_set("hyper:psk:token", b"existing-valid-psk")
2295            .await
2296            .unwrap();
2297        store
2298            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
2299            .await
2300            .unwrap();
2301
2302        let result = hyper
2303            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2304            .await;
2305
2306        mock.assert_async().await;
2307        assert!(
2308            result.is_ok(),
2309            "PSK renewal should succeed, got: {:?}",
2310            result.err()
2311        );
2312    }
2313
2314    /// An expired PSK should fall back to the manifest registration path.
2315    #[tokio::test]
2316    async fn bootstrap_expired_psk_falls_back_to_manifest() {
2317        let response_body = fake_register_response_bytes(true);
2318
2319        let mut server = mockito::Server::new_async().await;
2320        let mock = server
2321            .mock("POST", "/register")
2322            .with_status(200)
2323            .with_header("content-type", "application/x-protobuf")
2324            .with_body(response_body)
2325            .expect(1)
2326            .create_async()
2327            .await;
2328
2329        let dir = TempDir::new().unwrap();
2330        let config = dev_config(&dir);
2331        let hyper = Hyper::new(config).await.unwrap();
2332
2333        // Seed ActorStore with an expired PSK.
2334        let manifest = fake_manifest();
2335        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2336        let store = ActorStore::open(&storage_path).await.unwrap();
2337
2338        let expired_at = SystemTime::now()
2339            .duration_since(UNIX_EPOCH)
2340            .unwrap()
2341            .as_secs()
2342            .saturating_sub(10); // Expired 10 seconds ago.
2343        store
2344            .kv_set("hyper:psk:token", b"expired-psk")
2345            .await
2346            .unwrap();
2347        store
2348            .kv_set("hyper:psk:expires_at", &expired_at.to_le_bytes())
2349            .await
2350            .unwrap();
2351
2352        let result = hyper
2353            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2354            .await;
2355
2356        mock.assert_async().await;
2357        assert!(
2358            result.is_ok(),
2359            "Manifest registration should succeed after PSK expiration, got: {:?}",
2360            result.err()
2361        );
2362    }
2363
2364    /// AIS errors should propagate as HyperError::AisBootstrapFailed.
2365    #[tokio::test]
2366    async fn bootstrap_ais_error_propagates() {
2367        use actr_protocol::{ErrorResponse, RegisterResponse, register_response};
2368
2369        let error_resp = RegisterResponse {
2370            result: Some(register_response::Result::Error(ErrorResponse {
2371                code: 403,
2372                message: "manufacturer not trusted".to_string(),
2373            })),
2374        }
2375        .encode_to_vec();
2376
2377        let mut server = mockito::Server::new_async().await;
2378        let _mock = server
2379            .mock("POST", "/register")
2380            .with_status(200)
2381            .with_header("content-type", "application/x-protobuf")
2382            .with_body(error_resp)
2383            .create_async()
2384            .await;
2385
2386        let dir = TempDir::new().unwrap();
2387        let config = dev_config(&dir);
2388        let hyper = Hyper::new(config).await.unwrap();
2389
2390        let manifest = fake_manifest();
2391        let result = hyper
2392            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2393            .await;
2394
2395        assert!(
2396            matches!(result, Err(HyperError::AisBootstrapFailed(_))),
2397            "AIS errors should propagate as AisBootstrapFailed, got: {:?}",
2398            result
2399        );
2400    }
2401}