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    /// Escape-hatch constructor: wrap an already-built [`Hyper`] plus a
629    /// pre-loaded [`actr_config::RuntimeConfig`] into a [`Node<Init>`].
630    ///
631    /// Use this when you need direct control over `HyperConfig`
632    /// construction (custom trust chain, injected platform provider, etc.)
633    /// and cannot drive the whole flow through
634    /// [`Node::from_config_file`].
635    pub fn from_hyper(hyper: Hyper, runtime_config: actr_config::RuntimeConfig) -> Node<Init> {
636        Node {
637            hyper: hyper.inner,
638            attachment: None,
639            pending_runtime_config: Some(runtime_config),
640            _state: std::marker::PhantomData,
641        }
642    }
643
644    /// One-shot sugar: `from_config_file(path).attach(package).register().start()`.
645    ///
646    /// Loads the runtime configuration from `path`, attaches the given
647    /// workload package, registers with AIS at the `[ais_endpoint]` URL
648    /// from the config, and starts the node, returning a live
649    /// [`ActrRef`]. Use the typestate chain directly when you need to
650    /// interleave `create_network_event_handle` or swap in a custom
651    /// `service_spec` via `register_with`.
652    pub async fn run_from_config(
653        path: impl AsRef<std::path::Path>,
654        package: &WorkloadPackage,
655    ) -> HyperResult<ActrRef> {
656        let init = Self::from_config_file(path).await?;
657        let ais_endpoint = init
658            .pending_runtime_config
659            .as_ref()
660            .map(|c| c.ais_endpoint.clone())
661            .expect("Node<Init> without pending runtime config");
662        let attached = init.attach(package).await?;
663        let registered = attached.register(&ais_endpoint).await?;
664        registered
665            .start()
666            .await
667            .map_err(|e| HyperError::Runtime(format!("failed to start node: {e}")))
668    }
669}
670
671// ── Node<Init> accessors + state transition: Init → Attached ─────────────────
672
673#[cfg(not(target_arch = "wasm32"))]
674impl Node<Init> {
675    /// Read-only view of the runtime configuration pending attachment.
676    /// Useful for callers that need to configure observability /
677    /// tracing subscribers from the config before driving `attach`.
678    pub fn runtime_config(&self) -> &actr_config::RuntimeConfig {
679        self.pending_runtime_config
680            .as_ref()
681            .expect("Node<Init> without pending runtime config")
682    }
683
684    /// Override the runtime actor type before attaching or linking a workload.
685    ///
686    /// `Node::from_config_file` can synthesize a placeholder actor type when
687    /// the runtime config has no package manifest. Linked/static hosts use this
688    /// method to provide the concrete actor identity used for AIS registration.
689    pub fn with_actor_type(mut self, actor_type: actr_protocol::ActrType) -> Self {
690        let runtime_config = self
691            .pending_runtime_config
692            .as_mut()
693            .expect("Node<Init> without pending runtime config");
694        runtime_config.package.name = actor_type.name.clone();
695        runtime_config.package.actr_type = actor_type;
696        self
697    }
698}
699
700#[cfg(not(target_arch = "wasm32"))]
701impl Node<Init> {
702    /// Bind a verified [`WorkloadPackage`] to this node.
703    ///
704    /// Equivalent to the former `Hyper::attach` — verifies the package
705    /// signature through the configured `TrustProvider`, loads its guest
706    /// binary (WASM or dynclib), and advances the node to
707    /// `Node<Attached>`.
708    /// Attach a packaged workload (`wasm` / `dyn lib`) to this node.
709    pub async fn attach(self, package: &WorkloadPackage) -> HyperResult<Node<Attached>> {
710        let runtime_config = self
711            .pending_runtime_config
712            .expect("Node<Init> without pending runtime config");
713        let hyper_inner = self.hyper;
714        let loaded = load_workload_package_inner(&hyper_inner, package).await?;
715        let packaged_lock = actr_pack::read_lock_file(package.bytes())
716            .map_err(|e| HyperError::Runtime(e.to_string()))?
717            .map(|bytes| {
718                let raw = std::str::from_utf8(&bytes).map_err(|e| {
719                    HyperError::Runtime(format!("manifest.lock.toml is not valid UTF-8: {e}"))
720                })?;
721                actr_config::lock::LockFile::from_str(raw).map_err(|e| {
722                    HyperError::Runtime(format!("failed to parse manifest.lock.toml: {e}"))
723                })
724            })
725            .transpose()?;
726        let mailbox_backpressure_threshold =
727            hyper_inner.config.resolved_mailbox_backpressure_threshold();
728        let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
729        let mut node_inner = crate::lifecycle::node::Inner::build(
730            runtime_config,
731            loaded.workload,
732            Some(loaded.verified.manifest.clone()),
733            packaged_lock,
734            mailbox_backpressure_threshold,
735            credential_expiry_warning,
736        )
737        .await
738        .map_err(|e| HyperError::Runtime(e.to_string()))?;
739        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
740            Arc::new(crate::workload::PackageHookObserver {
741                workload_dispatch: node_inner.workload_dispatch.clone(),
742            });
743        node_inner.hook_observer = Some(observer);
744        Ok(Node {
745            hyper: hyper_inner,
746            attachment: Some(Attachment {
747                node: node_inner,
748                verified: Some(loaded.verified),
749                package_bytes: package.bytes.clone(),
750            }),
751            pending_runtime_config: None,
752            _state: std::marker::PhantomData,
753        })
754    }
755
756    /// Bind an internal workload handle for the `link` path to this node.
757    ///
758    /// No package is loaded; the host process *is* the workload. The
759    /// handle provides both observation hooks and an inbound-dispatch
760    /// entry point (see [`workload::LinkedWorkloadHandle::dispatch`]).
761    ///
762    /// Prefer [`Node::link`] when you already have a generic
763    /// [`actr_framework::Workload`] implementation — it wraps the
764    /// workload in a [`workload::WorkloadAdapter`] automatically.
765    pub(crate) async fn link_handle(
766        self,
767        handle: Arc<dyn workload::LinkedWorkloadHandle>,
768    ) -> HyperResult<Node<Attached>> {
769        let runtime_config = self
770            .pending_runtime_config
771            .expect("Node<Init> without pending runtime config");
772        let hyper_inner = self.hyper;
773        let mailbox_backpressure_threshold =
774            hyper_inner.config.resolved_mailbox_backpressure_threshold();
775        let credential_expiry_warning = hyper_inner.config.credential_expiry_warning;
776        let mut node_inner = crate::lifecycle::node::Inner::build(
777            runtime_config,
778            crate::workload::Workload::Linked(handle.clone()),
779            None,
780            None,
781            mailbox_backpressure_threshold,
782            credential_expiry_warning,
783        )
784        .await
785        .map_err(|e| HyperError::Runtime(e.to_string()))?;
786        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
787            Arc::new(crate::workload::LinkedHandleObserver { handle });
788        node_inner.hook_observer = Some(observer);
789        Ok(Node {
790            hyper: hyper_inner,
791            attachment: Some(Attachment {
792                node: node_inner,
793                verified: None,
794                package_bytes: bytes::Bytes::new(),
795            }),
796            pending_runtime_config: None,
797            _state: std::marker::PhantomData,
798        })
799    }
800
801    /// Link a generic [`actr_framework::Workload`] implementation
802    /// (`static lib`) into this node.
803    ///
804    /// This is the preferred `link` path for Rust hosts: the
805    /// workload is wrapped in a [`workload::WorkloadAdapter`] so that its
806    /// associated [`actr_framework::MessageDispatcher`] drives inbound
807    /// RPC dispatch and its hook methods are bridged into the node's
808    /// observer plumbing.
809    pub async fn link<W: actr_framework::Workload>(
810        self,
811        workload: W,
812    ) -> HyperResult<Node<Attached>> {
813        let handle: Arc<dyn workload::LinkedWorkloadHandle> =
814            workload::WorkloadAdapter::new(workload);
815        self.link_handle(handle).await
816    }
817}
818
819// ── State transition: Attached → Registered ──────────────────────────────────
820
821#[cfg(not(target_arch = "wasm32"))]
822impl Node<Attached> {
823    /// Add a host-side observer to an already attached node.
824    ///
825    /// Package-backed nodes keep their guest hook observer installed; this
826    /// method chains the supplied host observer after it so shells such as
827    /// mobile bindings can watch signaling/WebRTC readiness without replacing
828    /// package-delivered hooks.
829    pub fn with_hook_observer<W: actr_framework::Workload>(mut self, observer: W) -> Self {
830        let attachment = self
831            .attachment
832            .as_mut()
833            .expect("Node<Attached> without attachment");
834        let handle: Arc<dyn workload::LinkedWorkloadHandle> =
835            workload::WorkloadAdapter::new(observer);
836        let observer: Arc<dyn crate::lifecycle::hooks::WorkloadHookObserver> =
837            Arc::new(crate::workload::LinkedHandleObserver { handle });
838        attachment.node.hook_observer = crate::lifecycle::hooks::chain_observers(
839            attachment.node.hook_observer.take(),
840            Some(observer),
841        );
842        self
843    }
844
845    /// Register with AIS, obtain an AId credential, and inject it into this
846    /// attached node. Consumes `Node<Attached>` and returns `Node<Registered>`.
847    ///
848    /// `realm_id`, `acl`, and `realm_secret` come from the attached
849    /// [`RuntimeConfig`]; `service_spec` is derived from the package's proto
850    /// exports when a package-backed attach was used. Linked attachments
851    /// register from the runtime config's actor metadata instead.
852    pub async fn register(self, ais_endpoint: &str) -> HyperResult<Node<Registered>> {
853        let attachment = self
854            .attachment
855            .as_ref()
856            .expect("Node<Attached> without attachment");
857        let service_spec = if let Some(verified) = attachment.verified.as_ref() {
858            crate::service_spec::calculate_service_spec_from_package(
859                &attachment.package_bytes,
860                &verified.manifest,
861            )?
862        } else {
863            None
864        };
865        self.register_with(ais_endpoint, service_spec).await
866    }
867
868    /// Register with AIS using an explicit `service_spec`.
869    ///
870    /// This skips package-based `service_spec` derivation for
871    /// package-backed attachments. Linked attachments use the supplied
872    /// `service_spec` together with the runtime config's actor metadata.
873    pub async fn register_with(
874        mut self,
875        ais_endpoint: &str,
876        service_spec: Option<ServiceSpec>,
877    ) -> HyperResult<Node<Registered>> {
878        let attachment = self
879            .attachment
880            .as_mut()
881            .expect("Node<Attached> without attachment");
882        let realm_id = attachment.node.config.realm.realm_id;
883        let acl = attachment.node.config.acl.clone();
884        let realm_secret = attachment.node.config.realm_secret.clone();
885
886        let register_ok = if let Some(verified) = attachment.verified.as_ref() {
887            let verified = verified.clone();
888            bootstrap_credential_inner(
889                &self.hyper,
890                &verified,
891                ais_endpoint,
892                realm_id,
893                service_spec,
894                acl,
895                realm_secret.as_deref(),
896            )
897            .await?
898        } else {
899            bootstrap_linked_credential_inner(&attachment.node.config, ais_endpoint, service_spec)
900                .await?
901        };
902
903        attachment.node.set_preregistered_credential(register_ok);
904
905        Ok(Node {
906            hyper: self.hyper,
907            attachment: self.attachment,
908            pending_runtime_config: None,
909            _state: std::marker::PhantomData,
910        })
911    }
912
913    /// Create a network event handle for platform callbacks. Must be called
914    /// before [`Node::start`].
915    pub fn create_network_event_handle(
916        &mut self,
917        debounce_ms: u64,
918    ) -> crate::lifecycle::NetworkEventHandle {
919        self.attachment
920            .as_mut()
921            .expect("Node<Attached> without attachment")
922            .node
923            .create_network_event_handle(debounce_ms)
924    }
925
926    /// AIS endpoint URL resolved from the attached [`RuntimeConfig`].
927    /// Convenience accessor for callers that just drove `from_config_file`
928    /// + `attach` and need the endpoint to pass into `register`.
929    pub fn ais_endpoint(&self) -> &str {
930        &self
931            .attachment
932            .as_ref()
933            .expect("Node<Attached> without attachment")
934            .node
935            .config
936            .ais_endpoint
937    }
938}
939
940// ── State transition: Registered → ActrRef ───────────────────────────────────
941
942#[cfg(not(target_arch = "wasm32"))]
943impl Node<Registered> {
944    /// Start the attached, registered node and return the live [`ActrRef`].
945    pub async fn start(self) -> actr_protocol::ActorResult<crate::actr_ref::ActrRef> {
946        let Attachment { node, .. } = self
947            .attachment
948            .expect("Node<Registered> without attachment");
949        node.start().await
950    }
951
952    /// Create a network event handle for platform callbacks. Must be called
953    /// before [`Node::start`].
954    pub fn create_network_event_handle(
955        &mut self,
956        debounce_ms: u64,
957    ) -> crate::lifecycle::NetworkEventHandle {
958        self.attachment
959            .as_mut()
960            .expect("Node<Registered> without attachment")
961            .node
962            .create_network_event_handle(debounce_ms)
963    }
964}
965
966// ── Helpers available in all states ──────────────────────────────────────────
967
968// ── Hyper common helpers (framework operations that don't require attachment) ─
969
970#[cfg(not(target_arch = "wasm32"))]
971impl Hyper {
972    /// Resolve the storage namespace path for a verified manifest.
973    ///
974    /// The path is fixed here; all subsequent storage operations are isolated based on this path.
975    pub fn resolve_storage_path(&self, manifest: &PackageManifest) -> HyperResult<PathBuf> {
976        resolve_storage_path_for(&self.inner, manifest)
977    }
978
979    /// Bootstrap credential registration with AIS (two-phase flow).
980    ///
981    /// Hyper completes registration bootstrap on behalf of the Actor and returns the full AIS
982    /// registration payload.
983    ///
984    /// ## Two-Phase Logic
985    ///
986    /// - **Phase 1 (first registration)**: no valid PSK in ActorStore ->
987    ///   register with MFR-signed manifest -> AIS returns credential + PSK -> stored in ActorStore
988    /// - **Phase 2 (PSK renewal)**: valid PSK exists in ActorStore ->
989    ///   register directly with PSK -> AIS returns new credential
990    ///
991    /// ## Parameters
992    ///
993    /// - `verified`: verified package bundle (from `verify_package`) — carries
994    ///   the parsed manifest plus the raw manifest bytes and signature needed
995    ///   for phase-1 registration with AIS.
996    /// - `ais_endpoint`: AIS HTTP address, e.g. `"http://ais.example.com:8080"`
997    /// - `realm_id`: target Realm ID
998    /// - `service_spec`: optional protobuf API metadata published to discovery
999    /// - `acl`: optional access-control policy attached to the actor
1000    pub async fn bootstrap_credential(
1001        &self,
1002        verified: &VerifiedPackage,
1003        ais_endpoint: &str,
1004        realm_id: u32,
1005        service_spec: Option<ServiceSpec>,
1006        acl: Option<Acl>,
1007    ) -> HyperResult<register_response::RegisterOk> {
1008        bootstrap_credential_inner(
1009            &self.inner,
1010            verified,
1011            ais_endpoint,
1012            realm_id,
1013            service_spec,
1014            acl,
1015            None,
1016        )
1017        .await
1018    }
1019
1020    /// Current instance_id
1021    pub fn instance_id(&self) -> &str {
1022        &self.inner.instance_id
1023    }
1024
1025    /// Current configuration
1026    pub fn config(&self) -> &HyperConfig {
1027        &self.inner.config
1028    }
1029}
1030
1031#[cfg(not(target_arch = "wasm32"))]
1032fn resolve_storage_path_for(
1033    inner: &HyperInner,
1034    manifest: &PackageManifest,
1035) -> HyperResult<PathBuf> {
1036    let resolver = config::NamespaceResolver::new(&inner.config, &inner.instance_id)?
1037        .with_actor_type(&manifest.manufacturer, &manifest.name, &manifest.version);
1038    resolver.resolve(&inner.config.storage_path_template)
1039}
1040
1041/// Free-function counterpart of [`Hyper::load_workload_package`] —
1042/// shared by both [`Hyper::attach`] and `Node<Init>::attach` without
1043/// needing a `Hyper` handle to own the call.
1044#[cfg(not(target_arch = "wasm32"))]
1045pub(crate) async fn load_workload_package_inner(
1046    inner: &HyperInner,
1047    package: &WorkloadPackage,
1048) -> HyperResult<LoadedWorkload> {
1049    let bytes = package.bytes();
1050    let verified = inner.config.trust_provider.verify_package(bytes).await?;
1051    let binary_kind = detect_binary_kind(&verified.manifest)?;
1052    let workload = match binary_kind {
1053        BinaryKind::Wasm => load_wasm_workload_inner(inner, bytes, &verified.manifest).await?,
1054        BinaryKind::DynClib => load_dynclib_workload_inner(inner, bytes, &verified.manifest)?,
1055    };
1056    Ok(LoadedWorkload {
1057        verified,
1058        binary_kind,
1059        workload,
1060    })
1061}
1062
1063#[cfg(not(target_arch = "wasm32"))]
1064async fn load_wasm_workload_inner(
1065    _inner: &HyperInner,
1066    bytes: &[u8],
1067    manifest: &PackageManifest,
1068) -> HyperResult<crate::workload::Workload> {
1069    #[cfg(feature = "wasm-engine")]
1070    {
1071        // Refuse legacy core-module packages before attempting to compile
1072        // them — `Component::from_binary` already rejects them downstream
1073        // with an opaque "unknown binary format" error, so catching the
1074        // case here produces a migration-pointing message instead.
1075        if matches!(
1076            manifest.binary.resolved_kind(),
1077            actr_pack::BinaryKind::CoreModule
1078        ) {
1079            return Err(HyperError::InvalidManifest(format!(
1080                "package `{}` uses the legacy core wasm module format, which was retired in Phase 1. \
1081                 Rebuild with actr 0.2+ (`actr build`, target wasm32-wasip2 + wasm-component-ld 0.5.22+) \
1082                 to produce a Component Model binary, and set `binary.kind = \"component\"` in manifest.toml.",
1083                manifest.actr_type_str()
1084            )));
1085        }
1086
1087        let wasm_bytes = actr_pack::load_binary(bytes).map_err(|e| {
1088            HyperError::Runtime(format!(
1089                "failed to extract package binary `{}` for target `{}`: {e}",
1090                manifest.binary.path, manifest.binary.target
1091            ))
1092        })?;
1093        let host = crate::wasm::WasmHost::compile(&wasm_bytes).map_err(|e| {
1094            HyperError::Runtime(format!(
1095                "failed to compile WASM package target `{}`: {e}",
1096                manifest.binary.target
1097            ))
1098        })?;
1099        let mut instance = host.instantiate().await.map_err(|e| {
1100            HyperError::Runtime(format!(
1101                "failed to instantiate WASM package target `{}`: {e}",
1102                manifest.binary.target
1103            ))
1104        })?;
1105        instance
1106            .init(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
1107                version: actr_framework::guest::dynclib_abi::version::V1,
1108                actr_type: manifest.actr_type_str(),
1109                credential: Vec::new(),
1110                actor_id: Vec::new(),
1111                realm_id: 0,
1112            })
1113            .map_err(|e| {
1114                HyperError::Runtime(format!(
1115                    "failed to initialize WASM package target `{}`: {e}",
1116                    manifest.binary.target
1117                ))
1118            })?;
1119        Ok(crate::workload::Workload::Wasm(instance))
1120    }
1121
1122    #[cfg(not(feature = "wasm-engine"))]
1123    {
1124        let _ = (bytes, manifest);
1125        Err(HyperError::Runtime(
1126            "package target requires the `wasm-engine` feature, but it is not enabled".to_string(),
1127        ))
1128    }
1129}
1130
1131#[cfg(not(target_arch = "wasm32"))]
1132fn load_dynclib_workload_inner(
1133    _inner: &HyperInner,
1134    bytes: &[u8],
1135    manifest: &PackageManifest,
1136) -> HyperResult<crate::workload::Workload> {
1137    #[cfg(feature = "dynclib-engine")]
1138    {
1139        let cache_path = ensure_dynclib_cache_path(&_inner.config.data_dir, bytes, manifest)?;
1140        let host = load_dynclib_host_with_rebuild(&cache_path, bytes, manifest)?;
1141        let instance = host
1142            .instantiate(&actr_framework::guest::dynclib_abi::InitPayloadV1 {
1143                version: actr_framework::guest::dynclib_abi::version::V1,
1144                actr_type: manifest.actr_type_str(),
1145                credential: Vec::new(),
1146                actor_id: Vec::new(),
1147                realm_id: 0,
1148            })
1149            .map_err(|e| {
1150                HyperError::Runtime(format!(
1151                    "failed to initialize dynclib package target `{}`: {e}",
1152                    manifest.binary.target
1153                ))
1154            })?;
1155
1156        Ok(crate::workload::Workload::DynClib(
1157            crate::dynclib::DynClibWorkload::new(host, instance),
1158        ))
1159    }
1160
1161    #[cfg(not(feature = "dynclib-engine"))]
1162    {
1163        let _ = (bytes, manifest);
1164        Err(HyperError::Runtime(
1165            "package target requires the `dynclib-engine` feature, but it is not enabled"
1166                .to_string(),
1167        ))
1168    }
1169}
1170
1171#[cfg(not(target_arch = "wasm32"))]
1172async fn bootstrap_credential_inner(
1173    inner: &HyperInner,
1174    verified: &VerifiedPackage,
1175    ais_endpoint: &str,
1176    realm_id: u32,
1177    service_spec: Option<ServiceSpec>,
1178    acl: Option<Acl>,
1179    realm_secret: Option<&str>,
1180) -> HyperResult<register_response::RegisterOk> {
1181    let manifest = &verified.manifest;
1182    info!(
1183        actr_type = manifest.actr_type_str(),
1184        ais_endpoint, realm_id, "starting credential bootstrap with AIS"
1185    );
1186
1187    // 1. Open the Actor's secret store (via platform provider or direct ActorStore)
1188    let storage_path = resolve_storage_path_for(inner, manifest)?;
1189    let store: Arc<dyn KvStore> = if let Some(ref platform) = inner.platform {
1190        let ns = storage_path.to_string_lossy().to_string();
1191        platform
1192            .secret_store(&ns)
1193            .await
1194            .map_err(|e| HyperError::Storage(format!("failed to open secret store: {e}")))?
1195    } else {
1196        Arc::new(ActorStore::open(&storage_path).await?)
1197    };
1198
1199    // 2. Check if there is a valid PSK in ActorStore
1200    let valid_psk = load_valid_psk_dyn(&*store).await?;
1201
1202    // 3. Build RegisterRequest and send to AIS
1203    let mut ais = AisClient::new(ais_endpoint);
1204    if let Some(secret) = realm_secret {
1205        ais = ais.with_realm_secret(secret);
1206    }
1207
1208    let actr_type = ActrType {
1209        manufacturer: manifest.manufacturer.clone(),
1210        name: manifest.name.clone(),
1211        version: manifest.version.clone(),
1212    };
1213    let realm = Realm { realm_id };
1214
1215    let response = if let Some(psk_token) = valid_psk {
1216        // Phase 2: PSK renewal
1217        debug!(
1218            actr_type = manifest.actr_type_str(),
1219            "renewing credential using PSK"
1220        );
1221        let req = RegisterRequest {
1222            actr_type,
1223            realm,
1224            service_spec,
1225            acl,
1226            service: None,
1227            ws_address: None,
1228            manifest_raw: None,
1229            mfr_signature: None,
1230            psk_token: Some(psk_token.into()),
1231            target: Some(manifest.binary.target.clone()),
1232            auth_mode: Some(RegisterAuthMode::Package as i32),
1233        };
1234        ais.register_with_psk(req).await?
1235    } else {
1236        // Phase 1: first registration, carrying MFR manifest
1237        info!(
1238            actr_type = manifest.actr_type_str(),
1239            "first registration: registering with AIS using MFR manifest"
1240        );
1241
1242        let req = RegisterRequest {
1243            actr_type,
1244            realm,
1245            service_spec,
1246            acl,
1247            service: None,
1248            ws_address: None,
1249            manifest_raw: Some(verified.manifest_raw.clone().into()),
1250            mfr_signature: Some(verified.sig_raw.clone().into()),
1251            psk_token: None,
1252            target: Some(manifest.binary.target.clone()),
1253            auth_mode: Some(RegisterAuthMode::Package as i32),
1254        };
1255        ais.register_with_manifest(req).await?
1256    };
1257
1258    // 4. Process AIS response
1259    let ok = match response.result {
1260        Some(register_response::Result::Success(ok)) => ok,
1261        Some(register_response::Result::Error(e)) => {
1262            error!(
1263                actr_type = manifest.actr_type_str(),
1264                error_code = e.code,
1265                error_message = %e.message,
1266                "AIS registration returned error"
1267            );
1268            return Err(HyperError::AisBootstrapFailed(format!(
1269                "AIS rejected registration (code={}): {}",
1270                e.code, e.message
1271            )));
1272        }
1273        None => {
1274            error!(
1275                actr_type = manifest.actr_type_str(),
1276                "AIS response missing result field"
1277            );
1278            return Err(HyperError::AisBootstrapFailed(
1279                "AIS response missing result field".to_string(),
1280            ));
1281        }
1282    };
1283
1284    // 5a. If the response contains a PSK (first registration scenario), store it in ActorStore
1285    if let (Some(psk), Some(psk_expires_at)) = (&ok.psk, ok.psk_expires_at) {
1286        info!(
1287            actr_type = manifest.actr_type_str(),
1288            psk_expires_at, "received PSK from AIS, storing in ActorStore"
1289        );
1290        let expires_at_bytes = (psk_expires_at as u64).to_le_bytes().to_vec();
1291        store
1292            .batch(vec![
1293                KvOp::Set {
1294                    key: "hyper:psk:token".to_string(),
1295                    value: psk.to_vec(),
1296                },
1297                KvOp::Set {
1298                    key: "hyper:psk:expires_at".to_string(),
1299                    value: expires_at_bytes,
1300                },
1301            ])
1302            .await
1303            .map_err(|e| HyperError::Storage(format!("failed to store PSK: {e}")))?;
1304        debug!(
1305            actr_type = manifest.actr_type_str(),
1306            "PSK successfully persisted to ActorStore"
1307        );
1308    }
1309
1310    // 5b. Store signing_pubkey + signing_key_id (for AisKeyCache use)
1311    let pubkey_bytes = ok.signing_pubkey.to_vec();
1312    let key_id_bytes = ok.signing_key_id.to_le_bytes().to_vec();
1313    store
1314        .batch(vec![
1315            KvOp::Set {
1316                key: "hyper:ais:signing_pubkey".to_string(),
1317                value: pubkey_bytes,
1318            },
1319            KvOp::Set {
1320                key: "hyper:ais:signing_key_id".to_string(),
1321                value: key_id_bytes,
1322            },
1323        ])
1324        .await
1325        .map_err(|e| HyperError::Storage(format!("failed to store signing key: {e}")))?;
1326    debug!(
1327        actr_type = manifest.actr_type_str(),
1328        signing_key_id = ok.signing_key_id,
1329        "AIS signing public key persisted to ActorStore"
1330    );
1331
1332    info!(
1333        actr_type = manifest.actr_type_str(),
1334        credential_len = ok.credential.encode_to_vec().len(),
1335        "AIS credential bootstrap succeeded"
1336    );
1337
1338    Ok(ok)
1339}
1340
1341#[cfg(not(target_arch = "wasm32"))]
1342async fn bootstrap_linked_credential_inner(
1343    config: &actr_config::RuntimeConfig,
1344    ais_endpoint: &str,
1345    service_spec: Option<ServiceSpec>,
1346) -> HyperResult<register_response::RegisterOk> {
1347    let mut ais = AisClient::new(ais_endpoint);
1348    if let Some(ref secret) = config.realm_secret {
1349        ais = ais.with_realm_secret(secret.clone());
1350    }
1351
1352    let req = build_linked_register_request(config, service_spec);
1353    let response = ais.register_linked(req).await?;
1354    match response.result {
1355        Some(register_response::Result::Success(ok)) => Ok(ok),
1356        Some(register_response::Result::Error(e)) => Err(HyperError::AisBootstrapFailed(format!(
1357            "AIS rejected registration (code={}): {}",
1358            e.code, e.message
1359        ))),
1360        None => Err(HyperError::AisBootstrapFailed(
1361            "AIS response missing result field".to_string(),
1362        )),
1363    }
1364}
1365
1366#[cfg(not(target_arch = "wasm32"))]
1367fn build_linked_register_request(
1368    config: &actr_config::RuntimeConfig,
1369    service_spec: Option<ServiceSpec>,
1370) -> RegisterRequest {
1371    let ws_address = if let Some(port) = config.websocket_listen_port {
1372        let host = config
1373            .websocket_advertised_host
1374            .as_deref()
1375            .unwrap_or("127.0.0.1");
1376        Some(format!("ws://{}:{}", host, port))
1377    } else {
1378        None
1379    };
1380
1381    RegisterRequest {
1382        actr_type: config.actr_type().clone(),
1383        realm: config.realm,
1384        service_spec,
1385        acl: config.acl.clone(),
1386        service: None,
1387        ws_address,
1388        auth_mode: Some(RegisterAuthMode::Linked as i32),
1389        ..Default::default()
1390    }
1391}
1392
1393// ─── Helper functions (native-only) ──────────────────────────────────────────
1394
1395#[cfg(not(target_arch = "wasm32"))]
1396/// Load PSK from any KvStore implementation; returns PSK bytes if present and not expired
1397///
1398/// PSK expiration check: considered expired when current Unix timestamp (seconds) >= expires_at.
1399async fn load_valid_psk_dyn(store: &dyn KvStore) -> HyperResult<Option<Vec<u8>>> {
1400    let token = store
1401        .get("hyper:psk:token")
1402        .await
1403        .map_err(|e| HyperError::Storage(format!("failed to read PSK token: {e}")))?;
1404    let expires_at_raw = store
1405        .get("hyper:psk:expires_at")
1406        .await
1407        .map_err(|e| HyperError::Storage(format!("failed to read PSK expires_at: {e}")))?;
1408
1409    check_psk_expiry(token, expires_at_raw)
1410}
1411
1412/// Load PSK from ActorStore; returns PSK bytes if present and not expired, otherwise None
1413///
1414/// PSK expiration check: considered expired when current Unix timestamp (seconds) >= expires_at.
1415#[cfg(all(not(target_arch = "wasm32"), test))]
1416async fn load_valid_psk(store: &ActorStore) -> HyperResult<Option<Vec<u8>>> {
1417    let token = store.kv_get("hyper:psk:token").await?;
1418    let expires_at_raw = store.kv_get("hyper:psk:expires_at").await?;
1419
1420    check_psk_expiry(token, expires_at_raw)
1421}
1422
1423#[cfg(not(target_arch = "wasm32"))]
1424/// Check PSK expiry given pre-fetched token and expires_at values
1425fn check_psk_expiry(
1426    token: Option<Vec<u8>>,
1427    expires_at_raw: Option<Vec<u8>>,
1428) -> HyperResult<Option<Vec<u8>>> {
1429    match (token, expires_at_raw) {
1430        (Some(token), Some(expires_bytes)) => {
1431            // parse expiration time (u64 little-endian)
1432            if expires_bytes.len() != 8 {
1433                warn!("PSK expires_at has unexpected format, falling back to first registration");
1434                return Ok(None);
1435            }
1436            let expires_at = u64::from_le_bytes(expires_bytes.as_slice().try_into().unwrap());
1437
1438            // get current Unix timestamp (seconds)
1439            let now_secs = SystemTime::now()
1440                .duration_since(UNIX_EPOCH)
1441                .unwrap_or_default()
1442                .as_secs();
1443
1444            if now_secs >= expires_at {
1445                warn!(
1446                    psk_expires_at = expires_at,
1447                    now = now_secs,
1448                    "PSK expired, falling back to first registration"
1449                );
1450                Ok(None)
1451            } else {
1452                debug!(
1453                    psk_expires_at = expires_at,
1454                    now = now_secs,
1455                    remaining_secs = expires_at - now_secs,
1456                    "PSK valid, using PSK renewal path"
1457                );
1458                Ok(Some(token))
1459            }
1460        }
1461        _ => {
1462            debug!("no PSK in ActorStore, proceeding with first registration");
1463            Ok(None)
1464        }
1465    }
1466}
1467
1468#[cfg(not(target_arch = "wasm32"))]
1469#[cfg(not(target_arch = "wasm32"))]
1470fn detect_binary_kind(manifest: &PackageManifest) -> HyperResult<BinaryKind> {
1471    if manifest.binary.is_wasm_target() {
1472        return Ok(BinaryKind::Wasm);
1473    }
1474
1475    if is_compatible_native_target(&manifest.binary.target) {
1476        return Ok(BinaryKind::DynClib);
1477    }
1478
1479    Err(HyperError::InvalidManifest(format!(
1480        "unsupported binary target `{}` for host `{}-{}`; expected `wasm32-*` or a native target matching this host",
1481        manifest.binary.target,
1482        std::env::consts::ARCH,
1483        std::env::consts::OS,
1484    )))
1485}
1486
1487/// Check that `target` is a valid Rust target triple compatible with the current host.
1488///
1489/// A target triple has at least 3 segments (arch-vendor-os or arch-vendor-os-env).
1490/// We verify that the arch and OS components match the running host to reject
1491/// cross-platform cdylib packages early, rather than failing at `dlopen` time.
1492#[cfg(not(target_arch = "wasm32"))]
1493fn is_compatible_native_target(target: &str) -> bool {
1494    let segments: Vec<&str> = target.split('-').filter(|s| !s.is_empty()).collect();
1495    if segments.len() < 3 {
1496        return false;
1497    }
1498
1499    let target_arch = segments[0];
1500    // OS is typically the third segment (arch-vendor-os[-env]).
1501    let target_os = segments[2];
1502
1503    // Normalize arch names: Rust target triples use different names than std::env::consts::ARCH.
1504    let arch_matches = match (target_arch, std::env::consts::ARCH) {
1505        (a, b) if a == b => true,
1506        ("x86_64", "x86_64") => true,
1507        ("aarch64", "aarch64") => true,
1508        _ => false,
1509    };
1510
1511    // Normalize OS names: Rust target triples use e.g. "darwin" while consts::OS is "macos".
1512    let os_matches = match (target_os, std::env::consts::OS) {
1513        (a, b) if a == b => true,
1514        ("darwin", "macos") | ("macos", "darwin") => true,
1515        _ => false,
1516    };
1517
1518    arch_matches && os_matches
1519}
1520
1521#[cfg(all(
1522    not(target_arch = "wasm32"),
1523    feature = "dynclib-engine",
1524    target_os = "macos"
1525))]
1526fn dynclib_tempfile_suffix() -> &'static str {
1527    ".dylib"
1528}
1529
1530#[cfg(all(
1531    not(target_arch = "wasm32"),
1532    feature = "dynclib-engine",
1533    target_os = "linux"
1534))]
1535fn dynclib_tempfile_suffix() -> &'static str {
1536    ".so"
1537}
1538
1539#[cfg(all(
1540    not(target_arch = "wasm32"),
1541    feature = "dynclib-engine",
1542    target_os = "windows"
1543))]
1544fn dynclib_tempfile_suffix() -> &'static str {
1545    ".dll"
1546}
1547
1548#[cfg(all(
1549    not(target_arch = "wasm32"),
1550    feature = "dynclib-engine",
1551    not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
1552))]
1553fn dynclib_tempfile_suffix() -> &'static str {
1554    ".dynlib"
1555}
1556
1557#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1558const DYNCLIB_CACHE_DIR: &str = "dynclib-cache";
1559
1560#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1561fn dynclib_cache_dir(data_dir: &Path) -> PathBuf {
1562    data_dir.join(DYNCLIB_CACHE_DIR)
1563}
1564
1565#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1566fn dynclib_cache_path(data_dir: &Path, binary_hash: &[u8; 32]) -> PathBuf {
1567    dynclib_cache_dir(data_dir).join(format!(
1568        "{}{}",
1569        hex::encode(binary_hash),
1570        dynclib_tempfile_suffix()
1571    ))
1572}
1573
1574#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1575fn extract_dynclib_binary(bytes: &[u8], manifest: &PackageManifest) -> HyperResult<Vec<u8>> {
1576    actr_pack::load_binary(bytes).map_err(|e| {
1577        HyperError::Runtime(format!(
1578            "failed to extract package binary `{}` for target `{}`: {e}",
1579            manifest.binary.path, manifest.binary.target
1580        ))
1581    })
1582}
1583
1584#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1585fn write_dynclib_cache_file(cache_path: &Path, binary_bytes: &[u8]) -> HyperResult<()> {
1586    let cache_dir = cache_path.parent().ok_or_else(|| {
1587        HyperError::Runtime("dynclib cache path has no parent directory".to_string())
1588    })?;
1589    std::fs::create_dir_all(cache_dir).map_err(|e| {
1590        HyperError::Runtime(format!(
1591            "failed to create dynclib cache directory `{}`: {e}",
1592            cache_dir.display()
1593        ))
1594    })?;
1595
1596    let mut temp_file = tempfile::Builder::new()
1597        .prefix("actr-dynclib-")
1598        .tempfile_in(cache_dir)
1599        .map_err(|e| {
1600            HyperError::Runtime(format!(
1601                "failed to allocate dynclib cache temp file in `{}`: {e}",
1602                cache_dir.display()
1603            ))
1604        })?;
1605
1606    temp_file.write_all(binary_bytes).map_err(|e| {
1607        HyperError::Runtime(format!(
1608            "failed to write dynclib cache temp file `{}`: {e}",
1609            temp_file.path().display()
1610        ))
1611    })?;
1612    temp_file.flush().map_err(|e| {
1613        HyperError::Runtime(format!(
1614            "failed to flush dynclib cache temp file `{}`: {e}",
1615            temp_file.path().display()
1616        ))
1617    })?;
1618
1619    match temp_file.persist_noclobber(cache_path) {
1620        Ok(_) => Ok(()),
1621        Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
1622        Err(err) => Err(HyperError::Runtime(format!(
1623            "failed to persist dynclib cache file `{}`: {}",
1624            cache_path.display(),
1625            err.error
1626        ))),
1627    }
1628}
1629
1630#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1631fn ensure_dynclib_cache_path(
1632    data_dir: &Path,
1633    bytes: &[u8],
1634    manifest: &PackageManifest,
1635) -> HyperResult<PathBuf> {
1636    let binary_hash = manifest
1637        .binary
1638        .hash_bytes()
1639        .map_err(|e| HyperError::InvalidManifest(e.to_string()))?;
1640    let cache_path = dynclib_cache_path(data_dir, &binary_hash);
1641    if cache_path.exists() {
1642        return Ok(cache_path);
1643    }
1644
1645    let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
1646    write_dynclib_cache_file(&cache_path, &binary_bytes)?;
1647    Ok(cache_path)
1648}
1649
1650#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1651fn rebuild_dynclib_cache_file(
1652    cache_path: &Path,
1653    bytes: &[u8],
1654    manifest: &PackageManifest,
1655) -> HyperResult<()> {
1656    match std::fs::remove_file(cache_path) {
1657        Ok(()) => {}
1658        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1659        Err(err) => {
1660            return Err(HyperError::Runtime(format!(
1661                "failed to remove corrupt dynclib cache file `{}`: {err}",
1662                cache_path.display()
1663            )));
1664        }
1665    }
1666
1667    let binary_bytes = extract_dynclib_binary(bytes, manifest)?;
1668    write_dynclib_cache_file(cache_path, &binary_bytes)
1669}
1670
1671#[cfg(all(not(target_arch = "wasm32"), feature = "dynclib-engine"))]
1672fn load_dynclib_host_with_rebuild(
1673    cache_path: &Path,
1674    bytes: &[u8],
1675    manifest: &PackageManifest,
1676) -> HyperResult<crate::dynclib::DynclibHost> {
1677    match crate::dynclib::DynclibHost::load(cache_path) {
1678        Ok(host) => Ok(host),
1679        Err(first_err) => {
1680            warn!(
1681                path = %cache_path.display(),
1682                target = %manifest.binary.target,
1683                error = %first_err,
1684                "cached dynclib load failed, rebuilding cache once"
1685            );
1686            rebuild_dynclib_cache_file(cache_path, bytes, manifest)?;
1687            crate::dynclib::DynclibHost::load(cache_path).map_err(|second_err| {
1688                HyperError::Runtime(format!(
1689                    "failed to load dynclib package target `{}` from cache `{}` after rebuild; first load error: {first_err}; second load error: {second_err}",
1690                    manifest.binary.target,
1691                    cache_path.display()
1692                ))
1693            })
1694        }
1695    }
1696}
1697
1698#[cfg(not(target_arch = "wasm32"))]
1699/// Load an existing `instance_uid` or generate and persist a new one.
1700///
1701/// Used only when no `PlatformProvider` is injected; otherwise the provider's
1702/// `instance_uid()` is the source of truth.
1703async fn load_or_create_instance_uid_local(data_dir: &std::path::Path) -> HyperResult<String> {
1704    let id_file = data_dir.join(".hyper-instance-uid");
1705
1706    if id_file.exists() {
1707        let id = tokio::fs::read_to_string(&id_file)
1708            .await
1709            .map_err(|e| HyperError::Storage(format!("failed to read instance_uid file: {e}")))?;
1710        let id = id.trim().to_string();
1711        if !id.is_empty() {
1712            return Ok(id);
1713        }
1714        warn!("instance_uid file is empty; generating a new one");
1715    }
1716
1717    let new_id = Uuid::new_v4().to_string();
1718    tokio::fs::write(&id_file, &new_id)
1719        .await
1720        .map_err(|e| HyperError::Storage(format!("failed to write instance_uid file: {e}")))?;
1721    info!(instance_uid = %new_id, "generated a new Hyper instance_uid");
1722    Ok(new_id)
1723}
1724
1725#[cfg(all(not(target_arch = "wasm32"), test))]
1726mod tests {
1727    use super::*;
1728    use ed25519_dalek::SigningKey;
1729    use rand::rngs::OsRng;
1730    #[cfg(feature = "dynclib-engine")]
1731    use std::sync::{Arc, Barrier};
1732    use tempfile::TempDir;
1733
1734    fn dev_config(dir: &TempDir) -> HyperConfig {
1735        let signing_key = SigningKey::generate(&mut OsRng);
1736        let pubkey = signing_key.verifying_key().to_bytes();
1737        HyperConfig::new(
1738            dir.path(),
1739            Arc::new(crate::verify::StaticTrust::new(pubkey).unwrap()),
1740        )
1741    }
1742
1743    #[tokio::test]
1744    async fn init_creates_data_dir_and_instance_id() {
1745        let dir = TempDir::new().unwrap();
1746        let sub = dir.path().join("subdir/nested");
1747        let signing_key = SigningKey::generate(&mut OsRng);
1748        let config = HyperConfig::new(
1749            &sub,
1750            Arc::new(
1751                crate::verify::StaticTrust::new(signing_key.verifying_key().to_bytes()).unwrap(),
1752            ),
1753        );
1754
1755        let hyper = Hyper::new(config).await.unwrap();
1756        assert!(sub.exists());
1757        assert!(!hyper.instance_id().is_empty());
1758    }
1759
1760    #[tokio::test]
1761    async fn instance_id_is_stable_across_reinit() {
1762        let dir = TempDir::new().unwrap();
1763        let config1 = dev_config(&dir);
1764        let hyper1 = Hyper::new(config1).await.unwrap();
1765        let id1 = hyper1.instance_id().to_string();
1766
1767        let config2 = dev_config(&dir);
1768        let hyper2 = Hyper::new(config2).await.unwrap();
1769        let id2 = hyper2.instance_id().to_string();
1770
1771        assert_eq!(id1, id2, "instance_id should remain stable across restarts");
1772    }
1773
1774    #[tokio::test]
1775    async fn verify_package_rejects_non_wasm() {
1776        let dir = TempDir::new().unwrap();
1777        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
1778        let result = hyper
1779            .verify_package(&WorkloadPackage::new(b"not a wasm file".to_vec()))
1780            .await;
1781        assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
1782    }
1783
1784    #[tokio::test]
1785    async fn verify_package_rejects_non_actr_format() {
1786        let dir = TempDir::new().unwrap();
1787        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
1788
1789        // Non-.actr bytes should return InvalidManifest
1790        let result = hyper
1791            .verify_package(&WorkloadPackage::new(b"\0asm\x01\x00\x00\x00".to_vec()))
1792            .await;
1793        assert!(matches!(result, Err(HyperError::InvalidManifest(_))));
1794    }
1795
1796    // ─── PSK storage and expiration unit tests ──────────────────────────────
1797
1798    async fn open_test_store(dir: &TempDir) -> ActorStore {
1799        let db_path = dir.path().join("test.db");
1800        ActorStore::open(&db_path).await.unwrap()
1801    }
1802
1803    /// Store a valid PSK and verify that load_valid_psk returns it.
1804    #[tokio::test]
1805    async fn psk_valid_returns_token() {
1806        let dir = TempDir::new().unwrap();
1807        let store = open_test_store(&dir).await;
1808
1809        let psk_token = b"test-psk-secret".to_vec();
1810        // Set the expiry time to one hour from now.
1811        let expires_at = SystemTime::now()
1812            .duration_since(UNIX_EPOCH)
1813            .unwrap()
1814            .as_secs()
1815            + 3600;
1816
1817        store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
1818        store
1819            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
1820            .await
1821            .unwrap();
1822
1823        let result = load_valid_psk(&store).await.unwrap();
1824        assert_eq!(result, Some(psk_token), "A valid PSK should be returned");
1825    }
1826
1827    /// Store an expired PSK and verify that load_valid_psk returns None.
1828    #[tokio::test]
1829    async fn psk_expired_returns_none() {
1830        let dir = TempDir::new().unwrap();
1831        let store = open_test_store(&dir).await;
1832
1833        let psk_token = b"expired-psk".to_vec();
1834        // Set the expiry time to one second in the past.
1835        let expires_at = SystemTime::now()
1836            .duration_since(UNIX_EPOCH)
1837            .unwrap()
1838            .as_secs()
1839            .saturating_sub(1);
1840
1841        store.kv_set("hyper:psk:token", &psk_token).await.unwrap();
1842        store
1843            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
1844            .await
1845            .unwrap();
1846
1847        let result = load_valid_psk(&store).await.unwrap();
1848        assert_eq!(result, None, "An expired PSK should return None");
1849    }
1850
1851    /// load_valid_psk returns None when ActorStore has no PSK.
1852    #[tokio::test]
1853    async fn psk_absent_returns_none() {
1854        let dir = TempDir::new().unwrap();
1855        let store = open_test_store(&dir).await;
1856
1857        let result = load_valid_psk(&store).await.unwrap();
1858        assert_eq!(result, None, "Missing PSK should return None");
1859    }
1860
1861    /// load_valid_psk returns None if token exists without expires_at.
1862    #[tokio::test]
1863    async fn psk_missing_expires_at_returns_none() {
1864        let dir = TempDir::new().unwrap();
1865        let store = open_test_store(&dir).await;
1866
1867        store
1868            .kv_set("hyper:psk:token", b"orphan-token")
1869            .await
1870            .unwrap();
1871        // Intentionally leave expires_at unset.
1872
1873        let result = load_valid_psk(&store).await.unwrap();
1874        assert_eq!(result, None, "Missing expires_at should return None");
1875    }
1876
1877    // ─── AIS integration tests (mockito mock server) ────────────────────────
1878
1879    /// Helper: build a [`VerifiedPackage`] for tests.
1880    ///
1881    /// Uses the canonical `actr_pack::PackageManifest` shape wrapped with empty
1882    /// manifest_raw / sig_raw placeholders — bootstrap tests don't touch AIS's
1883    /// re-verification path, so those bytes are not inspected.
1884    fn fake_manifest() -> VerifiedPackage {
1885        VerifiedPackage {
1886            manifest: actr_pack::PackageManifest {
1887                manufacturer: "test-mfr".to_string(),
1888                name: "TestActor".to_string(),
1889                version: "0.1.0".to_string(),
1890                binary: actr_pack::BinaryEntry {
1891                    path: "bin/actor.wasm".to_string(),
1892                    target: "wasm32-wasip1".to_string(),
1893                    hash: "0".repeat(64),
1894                    size: None,
1895                    kind: None,
1896                },
1897                signature_algorithm: "ed25519".to_string(),
1898                signing_key_id: None,
1899                resources: vec![],
1900                proto_files: vec![],
1901                lock_file: None,
1902                metadata: actr_pack::ManifestMetadata::default(),
1903            },
1904            manifest_raw: vec![],
1905            sig_raw: vec![0u8; 64],
1906        }
1907    }
1908
1909    /// Helper: build valid RegisterResponse protobuf bytes with credential data.
1910    fn fake_register_response_bytes(with_psk: bool) -> Vec<u8> {
1911        use actr_protocol::{
1912            AIdCredential, ActrId, ActrType, IdentityClaims, Realm, RegisterResponse,
1913            TurnCredential, register_response,
1914        };
1915
1916        let claims = IdentityClaims {
1917            realm_id: 1,
1918            actor_id: "test-actor-id".to_string(),
1919            expires_at: u64::MAX,
1920        };
1921        let claims_bytes = claims.encode_to_vec();
1922
1923        let credential = AIdCredential {
1924            key_id: 1,
1925            claims: claims_bytes.into(),
1926            signature: vec![0u8; 64].into(),
1927        };
1928
1929        let actr_id = ActrId {
1930            realm: Realm { realm_id: 1 },
1931            serial_number: 42,
1932            r#type: ActrType {
1933                manufacturer: "test-mfr".to_string(),
1934                name: "TestActor".to_string(),
1935                version: "0.1.0".to_string(),
1936            },
1937        };
1938
1939        let turn = TurnCredential {
1940            username: "user".to_string(),
1941            password: "pass".to_string(),
1942            expires_at: u64::MAX,
1943        };
1944
1945        let mut ok = register_response::RegisterOk {
1946            actr_id,
1947            credential,
1948            turn_credential: turn,
1949            credential_expires_at: None,
1950            signaling_heartbeat_interval_secs: 30,
1951            signing_pubkey: vec![0u8; 32].into(),
1952            signing_key_id: 1,
1953            psk: None,
1954            psk_expires_at: None,
1955        };
1956
1957        if with_psk {
1958            ok.psk = Some(b"fresh-psk-from-ais".to_vec().into());
1959            ok.psk_expires_at = Some(
1960                (SystemTime::now()
1961                    .duration_since(UNIX_EPOCH)
1962                    .unwrap()
1963                    .as_secs()
1964                    + 86400) as i64,
1965            );
1966        }
1967
1968        RegisterResponse {
1969            result: Some(register_response::Result::Success(ok)),
1970        }
1971        .encode_to_vec()
1972    }
1973
1974    fn test_service_spec() -> Option<ServiceSpec> {
1975        Some(ServiceSpec {
1976            name: "EchoService".to_string(),
1977            description: Some("test service".to_string()),
1978            fingerprint: "fp-123".to_string(),
1979            protobufs: vec![],
1980            published_at: None,
1981            tags: vec!["latest".to_string()],
1982        })
1983    }
1984
1985    fn test_acl() -> Option<Acl> {
1986        Some(Acl { rules: vec![] })
1987    }
1988
1989    fn linked_runtime_config(dir: &TempDir) -> actr_config::RuntimeConfig {
1990        actr_config::RuntimeConfig {
1991            package: actr_config::PackageInfo {
1992                name: "LinkedActor".to_string(),
1993                actr_type: actr_protocol::ActrType {
1994                    manufacturer: "test-mfr".to_string(),
1995                    name: "LinkedActor".to_string(),
1996                    version: "0.1.0".to_string(),
1997                },
1998                description: None,
1999                authors: vec![],
2000                license: None,
2001            },
2002            signaling_url: url::Url::parse("ws://localhost:8081/signaling/ws").unwrap(),
2003            realm: Realm { realm_id: 7 },
2004            ais_endpoint: "http://localhost:8081/ais".to_string(),
2005            realm_secret: Some("test-realm-secret".to_string()),
2006            visible_in_discovery: true,
2007            acl: test_acl(),
2008            mailbox_path: None,
2009            scripts: std::collections::HashMap::new(),
2010            webrtc: actr_config::WebRtcConfig::default(),
2011            websocket_listen_port: Some(9100),
2012            websocket_advertised_host: Some("127.0.0.1".to_string()),
2013            observability: actr_config::ObservabilityConfig {
2014                filter_level: "info".to_string(),
2015                tracing_enabled: false,
2016                tracing_endpoint: "http://localhost:4317".to_string(),
2017                tracing_service_name: "linked-test".to_string(),
2018            },
2019            config_dir: dir.path().to_path_buf(),
2020            trust: vec![],
2021            package_path: None,
2022            web: None,
2023        }
2024    }
2025
2026    #[test]
2027    fn linked_register_request_uses_linked_auth_mode() {
2028        let dir = TempDir::new().unwrap();
2029        let req = build_linked_register_request(&linked_runtime_config(&dir), test_service_spec());
2030
2031        assert_eq!(req.auth_mode, Some(RegisterAuthMode::Linked as i32));
2032        assert_eq!(req.manifest_raw, None);
2033        assert_eq!(req.mfr_signature, None);
2034        assert_eq!(req.psk_token, None);
2035        assert_eq!(req.ws_address.as_deref(), Some("ws://127.0.0.1:9100"));
2036    }
2037
2038    #[tokio::test]
2039    async fn with_actor_type_overrides_pending_runtime_metadata() {
2040        let dir = TempDir::new().unwrap();
2041        let hyper = Hyper::new(dev_config(&dir)).await.unwrap();
2042        let node = Node::from_hyper(hyper, linked_runtime_config(&dir)).with_actor_type(
2043            actr_protocol::ActrType {
2044                manufacturer: "acme".into(),
2045                name: "UnifiedActor".into(),
2046                version: "1.0.0".into(),
2047            },
2048        );
2049
2050        let actr_type = node.runtime_config().actr_type();
2051        assert_eq!(actr_type.manufacturer, "acme");
2052        assert_eq!(actr_type.name, "UnifiedActor");
2053        assert_eq!(actr_type.version, "1.0.0");
2054    }
2055
2056    #[test]
2057    fn compatible_native_target_matches_current_host() {
2058        // Current host should always match itself.
2059        let current = format!(
2060            "{}-unknown-{}",
2061            std::env::consts::ARCH,
2062            if std::env::consts::OS == "macos" {
2063                "darwin"
2064            } else {
2065                std::env::consts::OS
2066            }
2067        );
2068        assert!(
2069            is_compatible_native_target(&current),
2070            "current host target `{current}` should be compatible"
2071        );
2072    }
2073
2074    #[test]
2075    fn compatible_native_target_rejects_cross_platform() {
2076        // A target for a different arch/os should be rejected.
2077        assert!(!is_compatible_native_target("riscv64gc-unknown-linux-gnu"));
2078        assert!(!is_compatible_native_target("s390x-unknown-linux-gnu"));
2079    }
2080
2081    #[test]
2082    fn compatible_native_target_rejects_short_triples() {
2083        assert!(!is_compatible_native_target("invalid-target"));
2084        assert!(!is_compatible_native_target("single"));
2085        assert!(!is_compatible_native_target(""));
2086    }
2087
2088    #[cfg(feature = "dynclib-engine")]
2089    fn fake_dynclib_manifest() -> PackageManifest {
2090        let target = format!(
2091            "{}-unknown-{}",
2092            std::env::consts::ARCH,
2093            if std::env::consts::OS == "macos" {
2094                "darwin"
2095            } else {
2096                std::env::consts::OS
2097            }
2098        );
2099        PackageManifest {
2100            manufacturer: "test-mfr".to_string(),
2101            name: "DynActor".to_string(),
2102            version: "1.0.0".to_string(),
2103            binary: actr_pack::BinaryEntry {
2104                path: format!("bin/actor{}", dynclib_tempfile_suffix()),
2105                target,
2106                hash: String::new(),
2107                size: None,
2108                kind: None,
2109            },
2110            signature_algorithm: "ed25519".to_string(),
2111            signing_key_id: None,
2112            resources: vec![],
2113            proto_files: vec![],
2114            lock_file: None,
2115            metadata: actr_pack::ManifestMetadata::default(),
2116        }
2117    }
2118
2119    #[cfg(feature = "dynclib-engine")]
2120    fn fake_dynclib_package_bytes(binary_bytes: &[u8]) -> (Vec<u8>, PackageManifest) {
2121        let manifest = fake_dynclib_manifest();
2122        let signing_key = SigningKey::generate(&mut OsRng);
2123        let package_bytes = actr_pack::pack(&actr_pack::PackOptions {
2124            manifest: manifest.clone(),
2125            binary_bytes: binary_bytes.to_vec(),
2126            resources: vec![],
2127            proto_files: vec![],
2128            lock_file: None,
2129            signing_key,
2130        })
2131        .unwrap();
2132        // `pack()` updates the embedded manifest's binary hash; re-parse so
2133        // the returned manifest agrees with what's actually in the archive.
2134        let packed_manifest = actr_pack::read_manifest(&package_bytes).unwrap();
2135        (package_bytes, packed_manifest)
2136    }
2137
2138    #[cfg(feature = "dynclib-engine")]
2139    #[test]
2140    fn dynclib_cache_path_uses_hash_and_platform_suffix() {
2141        let dir = TempDir::new().unwrap();
2142        let path = dynclib_cache_path(dir.path(), &[0xAB; 32]);
2143
2144        assert_eq!(path.parent().unwrap(), dynclib_cache_dir(dir.path()));
2145        assert_eq!(
2146            path.file_name().unwrap().to_string_lossy(),
2147            format!("{}{}", hex::encode([0xAB; 32]), dynclib_tempfile_suffix())
2148        );
2149    }
2150
2151    #[cfg(feature = "dynclib-engine")]
2152    #[test]
2153    fn ensure_dynclib_cache_path_preserves_existing_file() {
2154        let dir = TempDir::new().unwrap();
2155        let initial_binary_bytes = b"initial dylib bytes";
2156        let (initial_package_bytes, manifest) = fake_dynclib_package_bytes(initial_binary_bytes);
2157        let cache_path =
2158            ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
2159
2160        // Same initial binary -> same manifest.binary.hash -> same cache path;
2161        // a second call with a different binary under that hash cannot land
2162        // here, so re-run with the identical binary to assert idempotence.
2163        let second_path =
2164            ensure_dynclib_cache_path(dir.path(), &initial_package_bytes, &manifest).unwrap();
2165
2166        assert_eq!(cache_path, second_path);
2167        assert_eq!(std::fs::read(&cache_path).unwrap(), initial_binary_bytes);
2168    }
2169
2170    #[cfg(feature = "dynclib-engine")]
2171    #[test]
2172    fn ensure_dynclib_cache_path_handles_concurrent_creation() {
2173        let dir = TempDir::new().unwrap();
2174        let binary_bytes = b"shared dylib bytes".to_vec();
2175        let (package_bytes, manifest) = fake_dynclib_package_bytes(&binary_bytes);
2176        let package_bytes = Arc::new(package_bytes);
2177        let binary_bytes = Arc::new(binary_bytes);
2178        let data_dir = Arc::new(dir.path().to_path_buf());
2179        let barrier = Arc::new(Barrier::new(3));
2180
2181        let handles: Vec<_> = (0..2)
2182            .map(|_| {
2183                let barrier = Arc::clone(&barrier);
2184                let data_dir = Arc::clone(&data_dir);
2185                let manifest = manifest.clone();
2186                let package_bytes = Arc::clone(&package_bytes);
2187                std::thread::spawn(move || {
2188                    barrier.wait();
2189                    ensure_dynclib_cache_path(&data_dir, &package_bytes, &manifest)
2190                })
2191            })
2192            .collect();
2193
2194        barrier.wait();
2195
2196        let results: Vec<_> = handles
2197            .into_iter()
2198            .map(|handle| handle.join().unwrap().unwrap())
2199            .collect();
2200
2201        assert_eq!(results[0], results[1]);
2202        assert_eq!(
2203            std::fs::read(&results[0]).unwrap(),
2204            binary_bytes.as_ref().as_slice()
2205        );
2206    }
2207
2208    /// First registration with no PSK should store the PSK returned by AIS.
2209    #[tokio::test]
2210    async fn bootstrap_first_registration_stores_psk() {
2211        let response_body = fake_register_response_bytes(true);
2212
2213        let mut server = mockito::Server::new_async().await;
2214        let mock = server
2215            .mock("POST", "/register")
2216            .with_status(200)
2217            .with_header("content-type", "application/x-protobuf")
2218            .with_body(response_body)
2219            .create_async()
2220            .await;
2221
2222        let dir = TempDir::new().unwrap();
2223        let config = dev_config(&dir);
2224        let hyper = Hyper::new(config).await.unwrap();
2225
2226        let manifest = fake_manifest();
2227        let result = hyper
2228            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2229            .await;
2230
2231        mock.assert_async().await;
2232        assert!(
2233            result.is_ok(),
2234            "Initial registration should succeed, got: {:?}",
2235            result.err()
2236        );
2237
2238        // Verify the PSK was written to ActorStore.
2239        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2240        let store = ActorStore::open(&storage_path).await.unwrap();
2241        let psk = store.kv_get("hyper:psk:token").await.unwrap();
2242        assert!(
2243            psk.is_some(),
2244            "PSK should be stored in ActorStore after initial registration"
2245        );
2246        assert_eq!(psk.unwrap(), b"fresh-psk-from-ais".to_vec());
2247    }
2248
2249    /// A valid PSK should skip manifest registration and use the renewal path.
2250    #[tokio::test]
2251    async fn bootstrap_psk_renewal_skips_manifest() {
2252        let response_body = fake_register_response_bytes(false);
2253
2254        let mut server = mockito::Server::new_async().await;
2255        let mock = server
2256            .mock("POST", "/register")
2257            .with_status(200)
2258            .with_header("content-type", "application/x-protobuf")
2259            .with_body(response_body)
2260            .expect(1) // /register should be called exactly once.
2261            .create_async()
2262            .await;
2263
2264        let dir = TempDir::new().unwrap();
2265        let config = dev_config(&dir);
2266        let hyper = Hyper::new(config).await.unwrap();
2267
2268        // Seed ActorStore with a valid PSK.
2269        let manifest = fake_manifest();
2270        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2271        let store = ActorStore::open(&storage_path).await.unwrap();
2272
2273        let expires_at = SystemTime::now()
2274            .duration_since(UNIX_EPOCH)
2275            .unwrap()
2276            .as_secs()
2277            + 3600;
2278        store
2279            .kv_set("hyper:psk:token", b"existing-valid-psk")
2280            .await
2281            .unwrap();
2282        store
2283            .kv_set("hyper:psk:expires_at", &expires_at.to_le_bytes())
2284            .await
2285            .unwrap();
2286
2287        let result = hyper
2288            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2289            .await;
2290
2291        mock.assert_async().await;
2292        assert!(
2293            result.is_ok(),
2294            "PSK renewal should succeed, got: {:?}",
2295            result.err()
2296        );
2297    }
2298
2299    /// An expired PSK should fall back to the manifest registration path.
2300    #[tokio::test]
2301    async fn bootstrap_expired_psk_falls_back_to_manifest() {
2302        let response_body = fake_register_response_bytes(true);
2303
2304        let mut server = mockito::Server::new_async().await;
2305        let mock = server
2306            .mock("POST", "/register")
2307            .with_status(200)
2308            .with_header("content-type", "application/x-protobuf")
2309            .with_body(response_body)
2310            .expect(1)
2311            .create_async()
2312            .await;
2313
2314        let dir = TempDir::new().unwrap();
2315        let config = dev_config(&dir);
2316        let hyper = Hyper::new(config).await.unwrap();
2317
2318        // Seed ActorStore with an expired PSK.
2319        let manifest = fake_manifest();
2320        let storage_path = hyper.resolve_storage_path(&manifest.manifest).unwrap();
2321        let store = ActorStore::open(&storage_path).await.unwrap();
2322
2323        let expired_at = SystemTime::now()
2324            .duration_since(UNIX_EPOCH)
2325            .unwrap()
2326            .as_secs()
2327            .saturating_sub(10); // Expired 10 seconds ago.
2328        store
2329            .kv_set("hyper:psk:token", b"expired-psk")
2330            .await
2331            .unwrap();
2332        store
2333            .kv_set("hyper:psk:expires_at", &expired_at.to_le_bytes())
2334            .await
2335            .unwrap();
2336
2337        let result = hyper
2338            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2339            .await;
2340
2341        mock.assert_async().await;
2342        assert!(
2343            result.is_ok(),
2344            "Manifest registration should succeed after PSK expiration, got: {:?}",
2345            result.err()
2346        );
2347    }
2348
2349    /// AIS errors should propagate as HyperError::AisBootstrapFailed.
2350    #[tokio::test]
2351    async fn bootstrap_ais_error_propagates() {
2352        use actr_protocol::{ErrorResponse, RegisterResponse, register_response};
2353
2354        let error_resp = RegisterResponse {
2355            result: Some(register_response::Result::Error(ErrorResponse {
2356                code: 403,
2357                message: "manufacturer not trusted".to_string(),
2358            })),
2359        }
2360        .encode_to_vec();
2361
2362        let mut server = mockito::Server::new_async().await;
2363        let _mock = server
2364            .mock("POST", "/register")
2365            .with_status(200)
2366            .with_header("content-type", "application/x-protobuf")
2367            .with_body(error_resp)
2368            .create_async()
2369            .await;
2370
2371        let dir = TempDir::new().unwrap();
2372        let config = dev_config(&dir);
2373        let hyper = Hyper::new(config).await.unwrap();
2374
2375        let manifest = fake_manifest();
2376        let result = hyper
2377            .bootstrap_credential(&manifest, &server.url(), 1, test_service_spec(), test_acl())
2378            .await;
2379
2380        assert!(
2381            matches!(result, Err(HyperError::AisBootstrapFailed(_))),
2382            "AIS errors should propagate as AisBootstrapFailed, got: {:?}",
2383            result
2384        );
2385    }
2386}