diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 38899726..b72da9b0 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -166,6 +166,14 @@ PermissionsService CoreStorageKey::PermissionAuthorization { product_id, request } ``` +`HostAdmin::allocate_allowances(resources, origin)` allocates product-scoped +resources on the host's own initiative — startup warm-up, foreground re-check, +recovery after a rejection. It reaches the same authority operation as the +product-facing `ResourceAllocation` call but does not raise that call's +confirmation review, which exists to put a *product's* request to the user. A +host that wants to prompt for its own maintenance work decides that itself, and +`HostAllowanceOrigin` says which lifecycle moment asked. + The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for each product connection. Role-specific operations live only on the matching handle: diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 34c72994..4c906361 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -677,6 +677,31 @@ fn ring_vrf_admin_error( } } +/// Why the host, rather than a product, asked for an allowance operation. +/// +/// Carried through to tracing and available to host policy: a first-ever grant +/// may warrant a prompt where routine maintenance does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostAllowanceOrigin { + /// Warming allowances so a product's first call does not stall. + StartupReadiness, + /// Re-checking allowances as the host returns to the foreground. + ForegroundRenewal, + /// Re-acquiring an allowance after a failure or rejection. + Recovery, +} + +impl HostAllowanceOrigin { + /// Stable lowercase tag for tracing and correlation ids. + pub fn as_str(self) -> &'static str { + match self { + Self::StartupReadiness => "startup-readiness", + Self::ForegroundRenewal => "foreground-renewal", + Self::Recovery => "recovery", + } + } +} + /// Product-scoped administration handle for host UI. /// /// Host UI should use this when it needs to inspect or update core-owned state @@ -736,6 +761,26 @@ impl HostAdmin { .await } + /// Allocate product-scoped resources on the host's own initiative. + /// + /// The product-facing `ResourceAllocation` confirmation review is not + /// raised: it exists to put a product's request to the user, and no + /// product is asking. A host that wants to prompt for its own maintenance + /// work decides that itself, using `origin` to tell the cases apart. + /// + /// Returns one outcome per requested resource, in request order. No key + /// material crosses this boundary. + #[instrument(skip_all, fields(runtime.method = "host_admin.allocate_allowances"))] + pub async fn allocate_allowances( + &self, + resources: Vec, + origin: HostAllowanceOrigin, + ) -> Result, v01::GenericError> { + self.product_runtime + .allocate_resources_for_host(resources, origin) + .await + } + /// Update a stored permission authorization status. #[instrument(skip_all, fields(runtime.method = "host_admin.set_permission_authorization_status"))] pub async fn set_permission_authorization_status( diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index d9ba7b79..e03ec7e0 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -47,8 +47,8 @@ pub mod native_renderer; pub mod wasm; pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeControl, - ProductRuntimeError, SigningHostRuntime, + FrameSink, HostAdmin, HostAllowanceOrigin, PairingHostRuntime, ProductRuntime, + ProductRuntimeControl, ProductRuntimeError, SigningHostRuntime, }; pub use host_logic::session::{ ExternalPairedSession, SsoSessionInfo, decode_persisted_session, encode_external_paired_session, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a56627db..56e4d328 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -41,6 +41,9 @@ use std::time::Instant; use web_time::Instant; use crate::chain_runtime::RuntimeFailure; +use core::sync::atomic::{AtomicU64, Ordering}; + +use crate::host_core::HostAllowanceOrigin; use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, external_host, parse_navigate}; use crate::host_logic::features::{chain_info, feature_supported, supported_chains}; @@ -605,6 +608,52 @@ impl ProductRuntimeHost { service.set_authorization_status(&request, status).await } + /// Allocate product-scoped resources on the host's own initiative. + /// + /// Reaches the same authority operation as [`ResourceAllocation::request`] + /// without raising [`UserConfirmationReview::ResourceAllocation`]: that + /// review asks the user to approve a *product's* request, and there is no + /// product asking here. Hosts that want to prompt for a host-initiated + /// allocation own that decision, and `origin` records which lifecycle + /// moment asked so a host policy can branch on it. + #[instrument( + skip_all, + fields(runtime.method = "resource_allocation.host_request", origin = ?origin) + )] + pub(crate) async fn allocate_resources_for_host( + &self, + resources: Vec, + origin: HostAllowanceOrigin, + ) -> Result, v01::GenericError> { + let Some(session) = self.authority.current_session() else { + return Err(v01::GenericError { + reason: "No active session".to_string(), + }); + }; + let mut cx = CallContext::with_request_id(self.host_allowance_request_id(origin)); + cx.set_timeout(RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + let request = v01::HostRequestResourceAllocationRequest { resources }; + remote_authority_call( + &cx, + self.authority + .allocate_resources(&cx, &session, self.product_id(), request), + ) + .await + .map(|response| response.outcomes) + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + + /// Correlation id for a host-initiated allowance request. Unique per call + /// because the SSO channel matches responses on it, so two concurrent + /// host allocations must not share one. + fn host_allowance_request_id(&self, origin: HostAllowanceOrigin) -> String { + static NEXT_HOST_ALLOWANCE_REQUEST: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_HOST_ALLOWANCE_REQUEST.fetch_add(1, Ordering::Relaxed); + format!("host-allowance-{}-{sequence}", origin.as_str()) + } + #[instrument(skip_all, fields(runtime.method = "permissions.remote_authorization"))] async fn remote_permission_authorization( &self, @@ -5517,6 +5566,70 @@ mod tests { } } + #[test] + fn host_allowance_allocation_rejects_without_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let err = futures::executor::block_on(host.allocate_resources_for_host( + vec![v01::AllocatableResource::StatementStoreAllowance], + HostAllowanceOrigin::StartupReadiness, + )) + .unwrap_err(); + assert_eq!(err.reason, "No active session"); + } + + /// A product request on this platform raises the review and fails closed. + /// The host path must not consult it at all. Asserting the product path + /// first keeps the "no review" half from passing vacuously. + #[test] + fn host_allowance_allocation_raises_no_confirmation_review() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: false, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + install_pairing_session(&host, session_info()); + let reviews = || { + platform + .resource_allocation_reviews + .lock() + .expect("resource allocation review list mutex poisoned") + .len() + }; + + let cx = CallContext::default(); + let declined = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )); + assert!(declined.is_err(), "product path should fail closed here"); + assert_eq!(reviews(), 1, "product path must raise exactly one review"); + + let outcome = futures::executor::block_on(host.allocate_resources_for_host( + vec![v01::AllocatableResource::StatementStoreAllowance], + HostAllowanceOrigin::ForegroundRenewal, + )); + assert_eq!( + reviews(), + 1, + "host-initiated allocation must not raise the product confirmation review" + ); + // Whatever the stub authority answers, it must not be the decline the + // product path produces from the same platform. + if let Err(err) = outcome { + assert_ne!(err.reason, "User rejected resource allocation"); + } + } + + #[test] + fn host_allowance_request_ids_are_unique_per_call() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let first = host.host_allowance_request_id(HostAllowanceOrigin::Recovery); + let second = host.host_allowance_request_id(HostAllowanceOrigin::Recovery); + assert_ne!(first, second); + assert!(first.starts_with("host-allowance-recovery-"), "{first}"); + } + #[test] fn resource_allocation_rejects_when_user_declines() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner());