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