commit b159e1225161443c327cd0304c4620580e230505
parent 050f20d8ceae72d64cf8aff912a51624d6c05131
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 03:15:59 +0000
cli: enforce approval error metadata
- add a structured adapter approval error that maps to approval_required exit code 6
- enforce required account import and remove approvals outside dry-run paths
- update required approval services to use the shared adapter error
- cover approval metadata mapping across registry, core, seller, listing, and order tests
Diffstat:
5 files changed, 124 insertions(+), 18 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -7,7 +7,8 @@ use serde_json::{Map, Value};
use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation};
use crate::output_contract::{
- EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputWarning,
+ CliExitCode, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError,
+ OutputWarning,
};
use crate::target_cli::{TargetCliArgs, TargetOutputFormat};
@@ -298,10 +299,55 @@ pub enum OperationAdapterError {
operation_id: String,
message: String,
},
+ #[error("approval required for `{operation_id}`: {message}")]
+ ApprovalRequired {
+ operation_id: String,
+ message: String,
+ },
#[error("operation runtime error: {0}")]
Runtime(String),
}
+impl OperationAdapterError {
+ pub fn approval_required(operation_id: &str) -> Self {
+ Self::ApprovalRequired {
+ operation_id: operation_id.to_owned(),
+ message: "missing required `approval_token` input".to_owned(),
+ }
+ }
+
+ pub fn to_output_error(&self) -> OutputError {
+ match self {
+ Self::ApprovalRequired { message, .. } => OutputError::new(
+ "approval_required",
+ message.clone(),
+ CliExitCode::ApprovalRequiredOrDenied,
+ ),
+ Self::InvalidInput { message, .. } => {
+ OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput)
+ }
+ Self::UnknownOperation(operation_id) => OutputError::new(
+ "unknown_operation",
+ format!("unknown operation `{operation_id}`"),
+ CliExitCode::InvalidInput,
+ ),
+ Self::RequestTypeMismatch { .. } | Self::ResultTypeMismatch { .. } => OutputError::new(
+ "contract_mismatch",
+ self.to_string(),
+ CliExitCode::InternalError,
+ ),
+ Self::Serialization(message) => OutputError::new(
+ "serialization_failed",
+ message.clone(),
+ CliExitCode::InternalError,
+ ),
+ Self::Runtime(message) => {
+ OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError)
+ }
+ }
+ }
+}
+
macro_rules! mvp_operation_contracts {
($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => {
#[derive(Debug, Clone, PartialEq)]
@@ -538,9 +584,9 @@ mod tests {
use serde_json::json;
use super::{
- MvpOperationRequest, OperationAdapter, OperationContext, OperationInputMode,
- OperationNetworkMode, OperationOutputFormat, OperationRequest, OperationResult,
- OperationService, WorkspaceGetRequest, WorkspaceGetResult,
+ MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationContext,
+ OperationInputMode, OperationNetworkMode, OperationOutputFormat, OperationRequest,
+ OperationResult, OperationService, WorkspaceGetRequest, WorkspaceGetResult,
adapter_registry_linkage_is_valid,
};
use crate::operation_registry::OPERATION_REGISTRY;
@@ -654,4 +700,14 @@ mod tests {
assert_eq!(envelope.request_id, "req_test");
assert_eq!(envelope.result, json!({}));
}
+
+ #[test]
+ fn approval_errors_map_to_structured_exit_code() {
+ let error = OperationAdapterError::approval_required("order.submit");
+ let output_error = error.to_output_error();
+
+ assert_eq!(output_error.code, "approval_required");
+ assert_eq!(output_error.exit_code, 6);
+ assert!(output_error.message.contains("approval_token"));
+ }
}
diff --git a/src/operation_core.rs b/src/operation_core.rs
@@ -228,6 +228,11 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> {
"default": make_default,
}));
}
+ if request.context.approval_token.is_none() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
let account = map_runtime(import_public_identity(
self.config,
@@ -304,6 +309,11 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> {
"selector": selector,
}));
}
+ if request.context.approval_token.is_none() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
let result = map_runtime(remove_account(self.config, selector.as_str()))?;
json_operation_result::<AccountRemoveResult>(json!({
@@ -556,12 +566,14 @@ mod tests {
use radroots_runtime_paths::RadrootsMigrationReport;
use radroots_secret_vault::RadrootsSecretBackend;
+ use serde_json::{Map, Value};
use tempfile::tempdir;
use super::CoreOperationService;
use crate::operation_adapter::{
- AccountCreateRequest, AccountListRequest, OperationAdapter, OperationContext,
- OperationRequest, StoreStatusGetRequest, WorkspaceGetRequest,
+ AccountCreateRequest, AccountImportRequest, AccountListRequest, AccountRemoveRequest,
+ OperationAdapter, OperationContext, OperationData, OperationRequest, StoreStatusGetRequest,
+ WorkspaceGetRequest,
};
use crate::runtime::config::{
AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
@@ -656,6 +668,34 @@ mod tests {
assert_eq!(list_envelope.result["accounts"][0]["is_default"], true);
}
+ #[test]
+ fn core_required_account_approvals_return_approval_error() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let logging = LoggingState {
+ initialized: false,
+ current_file: None,
+ };
+ let service = OperationAdapter::new(CoreOperationService::new(&config, &logging));
+ let import = OperationRequest::new(
+ OperationContext::default(),
+ AccountImportRequest::from_data(data(&[("path", "account.json")])),
+ )
+ .expect("account import request");
+ let import_error = service.execute(import).expect_err("approval required");
+ assert_eq!(import_error.to_output_error().code, "approval_required");
+ assert_eq!(import_error.to_output_error().exit_code, 6);
+
+ let remove = OperationRequest::new(
+ OperationContext::default(),
+ AccountRemoveRequest::from_data(data(&[("selector", "acct_test")])),
+ )
+ .expect("account remove request");
+ let remove_error = service.execute(remove).expect_err("approval required");
+ assert_eq!(remove_error.to_output_error().code, "approval_required");
+ assert_eq!(remove_error.to_output_error().exit_code, 6);
+ }
+
fn sample_config(root: &Path) -> RuntimeConfig {
let data = root.join("data");
let logs = root.join("logs");
@@ -748,4 +788,11 @@ mod tests {
capability_bindings: Vec::new(),
}
}
+
+ fn data(entries: &[(&str, &str)]) -> OperationData {
+ entries
+ .iter()
+ .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned())))
+ .collect::<Map<String, Value>>()
+ }
}
diff --git a/src/operation_farm.rs b/src/operation_farm.rs
@@ -157,10 +157,9 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> {
}));
}
if request.context.approval_token.is_none() {
- return Err(OperationAdapterError::InvalidInput {
- operation_id: request.operation_id().to_owned(),
- message: "missing required `approval_token` input".to_owned(),
- });
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
}
let view = map_runtime(crate::runtime::farm::publish(self.config, &args))?;
@@ -411,6 +410,8 @@ mod tests {
.expect("farm publish request");
let error = service.execute(request).expect_err("approval required");
assert!(format!("{error}").contains("approval_token"));
+ assert_eq!(error.to_output_error().code, "approval_required");
+ assert_eq!(error.to_output_error().exit_code, 6);
}
fn sample_config(root: &Path) -> RuntimeConfig {
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -206,10 +206,9 @@ where
P: OperationRequestPayload + OperationRequestData,
{
if request.context.approval_token.is_none() {
- return Err(OperationAdapterError::InvalidInput {
- operation_id: request.operation_id().to_owned(),
- message: "missing required `approval_token` input".to_owned(),
- });
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
}
Ok(())
}
@@ -372,6 +371,8 @@ mod tests {
.expect("listing publish request");
let publish_error = service.execute(publish).expect_err("approval required");
assert!(format!("{publish_error}").contains("approval_token"));
+ assert_eq!(publish_error.to_output_error().code, "approval_required");
+ assert_eq!(publish_error.to_output_error().exit_code, 6);
let mut context = OperationContext::default();
context.dry_run = true;
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -31,10 +31,9 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> {
request: OperationRequest<OrderSubmitRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
if !request.context.dry_run && request.context.approval_token.is_none() {
- return Err(OperationAdapterError::InvalidInput {
- operation_id: request.operation_id().to_owned(),
- message: "missing required `approval_token` input".to_owned(),
- });
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
}
let key = required_order_key(&request)?;
@@ -294,6 +293,8 @@ mod tests {
let error = service.execute(submit).expect_err("approval required");
assert!(format!("{error}").contains("approval_token"));
+ assert_eq!(error.to_output_error().code, "approval_required");
+ assert_eq!(error.to_output_error().exit_code, 6);
}
#[test]