diff --git a/Cargo.lock b/Cargo.lock index 22a6600..693be66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,8 @@ dependencies = [ "rustls", "rustls-acme", "rustls-pki-types", + "serde", + "serde_json", "ssh-key", "tempfile", "thiserror 2.0.18", diff --git a/crates/alknet-core/Cargo.toml b/crates/alknet-core/Cargo.toml index 6f73df6..f25d4ff 100644 --- a/crates/alknet-core/Cargo.toml +++ b/crates/alknet-core/Cargo.toml @@ -36,6 +36,8 @@ url = { version = "2", optional = true } async-trait = "0.1" ipnetwork = "0.21.1" arc-swap = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] alknet-core = { path = ".", features = ["testutil", "tls", "iroh"] } diff --git a/crates/alknet-core/src/call/context.rs b/crates/alknet-core/src/call/context.rs new file mode 100644 index 0000000..1c70fe1 --- /dev/null +++ b/crates/alknet-core/src/call/context.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::call::OperationEnv; + +#[derive(Debug, Clone)] +pub struct OperationContext { + pub request_id: String, + pub parent_request_id: Option, + pub identity: Option, + pub metadata: HashMap, + pub env: OperationEnv, + pub trusted: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::call::OperationRegistry; + + fn make_context() -> OperationContext { + let registry = OperationRegistry::new(); + OperationContext { + request_id: "req-1".to_string(), + parent_request_id: None, + identity: None, + metadata: HashMap::new(), + env: OperationEnv::local(registry), + trusted: false, + } + } + + #[test] + fn operation_context_fields() { + let ctx = make_context(); + assert_eq!(ctx.request_id, "req-1"); + assert!(ctx.parent_request_id.is_none()); + assert!(ctx.identity.is_none()); + assert!(ctx.metadata.is_empty()); + assert!(!ctx.trusted); + } + + #[test] + fn operation_context_with_parent() { + let registry = OperationRegistry::new(); + let ctx = OperationContext { + request_id: "req-2".to_string(), + parent_request_id: Some("req-1".to_string()), + identity: None, + metadata: HashMap::new(), + env: OperationEnv::local(registry), + trusted: true, + }; + assert_eq!(ctx.parent_request_id, Some("req-1".to_string())); + assert!(ctx.trusted); + } +} diff --git a/crates/alknet-core/src/call/env.rs b/crates/alknet-core/src/call/env.rs new file mode 100644 index 0000000..98aa639 --- /dev/null +++ b/crates/alknet-core/src/call/env.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use serde_json::Value; + +use crate::call::context::OperationContext; +use crate::call::registry::OperationRegistry; +use crate::call::response::ResponseEnvelope; + +#[derive(Debug, Clone)] +pub struct OperationEnv { + registry: Arc, +} + +impl OperationEnv { + pub fn local(registry: OperationRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } + + pub fn invoke(&self, namespace: &str, operation: &str, input: Value) -> ResponseEnvelope { + let name = format!("{namespace}/{operation}"); + let request_id = format!("env-{name}"); + let context = OperationContext { + request_id: request_id.clone(), + parent_request_id: None, + identity: None, + metadata: std::collections::HashMap::new(), + env: self.clone(), + trusted: true, + }; + self.registry.invoke(&name, input, context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::call::registry::OperationRegistryBuilder; + use crate::call::spec::{AccessControl, OperationSpec, OperationType}; + + fn make_spec(name: &str, namespace: &str) -> OperationSpec { + OperationSpec { + name: name.to_string(), + namespace: namespace.to_string(), + op_type: OperationType::Query, + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + access_control: AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }, + } + } + + #[test] + fn operation_env_local_invoke() { + let registry = OperationRegistryBuilder::new() + .with( + make_spec("auth/verify", "auth"), + Arc::new(|_input, _ctx| { + ResponseEnvelope::ok("env-auth/verify", serde_json::json!({"verified": true})) + }), + ) + .build(); + + let env = OperationEnv::local(registry); + let result = env.invoke("auth", "verify", serde_json::json!({"token": "abc"})); + assert!(result.result.is_ok()); + } + + #[test] + fn operation_env_invoke_missing() { + let registry = OperationRegistry::new(); + let env = OperationEnv::local(registry); + let result = env.invoke("auth", "verify", serde_json::json!(null)); + assert!(result.result.is_err()); + let err = result.result.unwrap_err(); + assert_eq!(err.code, "NOT_FOUND"); + } + + #[test] + fn operation_env_invoke_trusted() { + let registry = OperationRegistryBuilder::new() + .with( + make_spec("auth/verify", "auth"), + Arc::new(|_input, ctx| { + assert!(ctx.trusted); + ResponseEnvelope::ok(&ctx.request_id, serde_json::json!({"ok": true})) + }), + ) + .build(); + + let env = OperationEnv::local(registry); + let result = env.invoke("auth", "verify", serde_json::json!(null)); + assert!(result.result.is_ok()); + } +} diff --git a/crates/alknet-core/src/call/mod.rs b/crates/alknet-core/src/call/mod.rs new file mode 100644 index 0000000..1d75a18 --- /dev/null +++ b/crates/alknet-core/src/call/mod.rs @@ -0,0 +1,11 @@ +pub mod context; +pub mod env; +pub mod registry; +pub mod response; +pub mod spec; + +pub use context::OperationContext; +pub use env::OperationEnv; +pub use registry::{Handler, OperationRegistry, OperationRegistryBuilder}; +pub use response::{CallError, ResponseEnvelope}; +pub use spec::{AccessControl, OperationSpec, OperationType}; diff --git a/crates/alknet-core/src/call/registry.rs b/crates/alknet-core/src/call/registry.rs new file mode 100644 index 0000000..3958c6e --- /dev/null +++ b/crates/alknet-core/src/call/registry.rs @@ -0,0 +1,337 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::Value; + +use crate::call::context::OperationContext; +use crate::call::response::ResponseEnvelope; +use crate::call::spec::OperationSpec; + +pub type Handler = Arc ResponseEnvelope + Send + Sync>; + +pub struct OperationRegistry { + operations: HashMap, +} + +impl std::fmt::Debug for OperationRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OperationRegistry") + .field("operation_count", &self.operations.len()) + .finish() + } +} + +impl OperationRegistry { + pub fn new() -> Self { + Self { + operations: HashMap::new(), + } + } + + pub fn register(&mut self, spec: OperationSpec, handler: Handler) { + self.operations.insert(spec.name.clone(), (spec, handler)); + } + + pub fn lookup(&self, name: &str) -> Option<(&OperationSpec, &Handler)> { + self.operations + .get(name) + .map(|(spec, handler)| (spec, handler)) + } + + pub fn invoke(&self, name: &str, input: Value, context: OperationContext) -> ResponseEnvelope { + match self.lookup(name) { + Some((spec, handler)) => { + if !context.trusted { + if let Some(ref identity) = context.identity { + if !spec.access_control.check(identity) { + return ResponseEnvelope::err( + &context.request_id, + "FORBIDDEN", + "access denied", + false, + ); + } + } else if spec.access_control.has_restrictions() { + return ResponseEnvelope::err( + &context.request_id, + "FORBIDDEN", + "authentication required", + false, + ); + } + } + handler(input, context) + } + None => ResponseEnvelope::err( + &context.request_id, + "NOT_FOUND", + format!("operation not found: {name}"), + false, + ), + } + } + + pub fn list_operations(&self) -> Vec<&OperationSpec> { + self.operations.values().map(|(spec, _)| spec).collect() + } +} + +impl Default for OperationRegistry { + fn default() -> Self { + Self::new() + } +} + +pub struct OperationRegistryBuilder { + registry: OperationRegistry, +} + +impl OperationRegistryBuilder { + pub fn new() -> Self { + Self { + registry: OperationRegistry::new(), + } + } + + pub fn with(mut self, spec: OperationSpec, handler: Handler) -> Self { + self.registry.register(spec, handler); + self + } + + pub fn build(self) -> OperationRegistry { + self.registry + } +} + +impl Default for OperationRegistryBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::Identity; + use crate::call::env::OperationEnv; + use crate::call::spec::{AccessControl, OperationType}; + use std::collections::HashMap; + + fn make_spec(name: &str, namespace: &str) -> OperationSpec { + OperationSpec { + name: name.to_string(), + namespace: namespace.to_string(), + op_type: OperationType::Query, + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + access_control: AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }, + } + } + + fn make_spec_with_acl(name: &str, namespace: &str, acl: AccessControl) -> OperationSpec { + OperationSpec { + name: name.to_string(), + namespace: namespace.to_string(), + op_type: OperationType::Mutation, + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + access_control: acl, + } + } + + fn make_context(request_id: &str, identity: Option) -> OperationContext { + let registry = OperationRegistry::new(); + OperationContext { + request_id: request_id.to_string(), + parent_request_id: None, + identity, + metadata: HashMap::new(), + env: OperationEnv::local(registry), + trusted: false, + } + } + + #[test] + fn register_and_lookup() { + let mut registry = OperationRegistry::new(); + let spec = make_spec("fs/readFile", "fs"); + let handler: Handler = Arc::new(|input, _ctx| ResponseEnvelope::ok("req-1", input)); + registry.register(spec, handler); + let found = registry.lookup("fs/readFile"); + assert!(found.is_some()); + let (spec, _) = found.unwrap(); + assert_eq!(spec.name, "fs/readFile"); + assert_eq!(spec.namespace, "fs"); + } + + #[test] + fn lookup_missing_returns_none() { + let registry = OperationRegistry::new(); + assert!(registry.lookup("missing").is_none()); + } + + #[test] + fn invoke_operation() { + let mut registry = OperationRegistry::new(); + let spec = make_spec("fs/readFile", "fs"); + let handler: Handler = Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)); + registry.register(spec, handler); + let context = make_context("req-1", None); + let result = registry.invoke("fs/readFile", serde_json::json!({"path": "/tmp"}), context); + assert!(result.result.is_ok()); + assert_eq!(result.result.unwrap(), serde_json::json!({"path": "/tmp"})); + } + + #[test] + fn invoke_missing_operation() { + let registry = OperationRegistry::new(); + let context = make_context("req-1", None); + let result = registry.invoke("missing", serde_json::json!(null), context); + assert!(result.result.is_err()); + let err = result.result.unwrap_err(); + assert_eq!(err.code, "NOT_FOUND"); + } + + #[test] + fn invoke_with_acl_check_allowed() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + required_scopes: vec!["read".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let spec = make_spec_with_acl("bash/exec", "bash", acl); + let handler: Handler = Arc::new(|_input, ctx| { + ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done")) + }); + registry.register(spec, handler); + + let identity = Identity { + id: "user-1".to_string(), + scopes: vec!["read".to_string()], + resources: HashMap::new(), + }; + let context = make_context("req-1", Some(identity)); + let result = registry.invoke("bash/exec", serde_json::json!(null), context); + assert!(result.result.is_ok()); + } + + #[test] + fn invoke_with_acl_check_denied() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + required_scopes: vec!["admin".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let spec = make_spec_with_acl("bash/exec", "bash", acl); + let handler: Handler = Arc::new(|_input, ctx| { + ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done")) + }); + registry.register(spec, handler); + + let identity = Identity { + id: "user-1".to_string(), + scopes: vec!["read".to_string()], + resources: HashMap::new(), + }; + let context = make_context("req-1", Some(identity)); + let result = registry.invoke("bash/exec", serde_json::json!(null), context); + assert!(result.result.is_err()); + let err = result.result.unwrap_err(); + assert_eq!(err.code, "FORBIDDEN"); + } + + #[test] + fn invoke_trusted_skips_acl() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + required_scopes: vec!["admin".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let spec = make_spec_with_acl("bash/exec", "bash", acl); + let handler: Handler = Arc::new(|_input, ctx| { + ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done")) + }); + registry.register(spec, handler); + + let identity = Identity { + id: "user-1".to_string(), + scopes: vec!["read".to_string()], + resources: HashMap::new(), + }; + let mut registry2 = OperationRegistry::new(); + let context = OperationContext { + request_id: "req-1".to_string(), + parent_request_id: None, + identity: Some(identity), + metadata: HashMap::new(), + env: OperationEnv::local(registry2), + trusted: true, + }; + let result = registry.invoke("bash/exec", serde_json::json!(null), context); + assert!(result.result.is_ok()); + } + + #[test] + fn invoke_no_identity_with_acl_denied() { + let mut registry = OperationRegistry::new(); + let acl = AccessControl { + required_scopes: vec!["read".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let spec = make_spec_with_acl("bash/exec", "bash", acl); + let handler: Handler = Arc::new(|_input, ctx| { + ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done")) + }); + registry.register(spec, handler); + + let context = make_context("req-1", None); + let result = registry.invoke("bash/exec", serde_json::json!(null), context); + assert!(result.result.is_err()); + let err = result.result.unwrap_err(); + assert_eq!(err.code, "FORBIDDEN"); + } + + #[test] + fn list_operations() { + let mut registry = OperationRegistry::new(); + registry.register( + make_spec("fs/readFile", "fs"), + Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))), + ); + registry.register( + make_spec("bash/exec", "bash"), + Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))), + ); + let ops = registry.list_operations(); + assert_eq!(ops.len(), 2); + } + + #[test] + fn registry_builder() { + let registry = OperationRegistryBuilder::new() + .with( + make_spec("fs/readFile", "fs"), + Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)), + ) + .with( + make_spec("bash/exec", "bash"), + Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)), + ) + .build(); + assert!(registry.lookup("fs/readFile").is_some()); + assert!(registry.lookup("bash/exec").is_some()); + } +} diff --git a/crates/alknet-core/src/call/response.rs b/crates/alknet-core/src/call/response.rs new file mode 100644 index 0000000..405c47c --- /dev/null +++ b/crates/alknet-core/src/call/response.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CallError { + pub code: String, + pub message: String, + pub retryable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseEnvelope { + pub request_id: String, + pub result: Result, +} + +impl ResponseEnvelope { + pub fn ok(request_id: impl Into, value: Value) -> Self { + Self { + request_id: request_id.into(), + result: Ok(value), + } + } + + pub fn err( + request_id: impl Into, + code: impl Into, + message: impl Into, + retryable: bool, + ) -> Self { + Self { + request_id: request_id.into(), + result: Err(CallError { + code: code.into(), + message: message.into(), + retryable, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn call_error_fields() { + let err = CallError { + code: "NOT_FOUND".to_string(), + message: "operation not found".to_string(), + retryable: false, + }; + assert_eq!(err.code, "NOT_FOUND"); + assert_eq!(err.message, "operation not found"); + assert!(!err.retryable); + } + + #[test] + fn response_envelope_ok() { + let env = ResponseEnvelope::ok("req-1", json!({"status": "ok"})); + assert_eq!(env.request_id, "req-1"); + assert!(env.result.is_ok()); + assert_eq!(env.result.unwrap(), json!({"status": "ok"})); + } + + #[test] + fn response_envelope_err() { + let env = ResponseEnvelope::err("req-1", "NOT_FOUND", "operation not found", false); + assert_eq!(env.request_id, "req-1"); + assert!(env.result.is_err()); + let err = env.result.unwrap_err(); + assert_eq!(err.code, "NOT_FOUND"); + assert_eq!(err.message, "operation not found"); + assert!(!err.retryable); + } + + #[test] + fn response_envelope_serialization() { + let env = ResponseEnvelope::ok("req-1", json!({"key": "value"})); + let serialized = serde_json::to_string(&env).unwrap(); + let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.request_id, "req-1"); + assert!(deserialized.result.is_ok()); + } + + #[test] + fn response_envelope_err_serialization() { + let env = ResponseEnvelope::err("req-2", "TIMEOUT", "timed out", true); + let serialized = serde_json::to_string(&env).unwrap(); + let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.request_id, "req-2"); + let err = deserialized.result.unwrap_err(); + assert_eq!(err.code, "TIMEOUT"); + assert!(err.retryable); + } +} diff --git a/crates/alknet-core/src/call/spec.rs b/crates/alknet-core/src/call/spec.rs new file mode 100644 index 0000000..17a0571 --- /dev/null +++ b/crates/alknet-core/src/call/spec.rs @@ -0,0 +1,233 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum OperationType { + Query, + Mutation, + Subscription, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessControl { + pub required_scopes: Vec, + pub required_scopes_any: Option>, + pub resource_type: Option, + pub resource_action: Option, +} + +impl AccessControl { + pub fn check(&self, identity: &crate::auth::Identity) -> bool { + for scope in &self.required_scopes { + if !identity.scopes.contains(scope) { + return false; + } + } + if let Some(any) = &self.required_scopes_any { + if !any.iter().any(|s| identity.scopes.contains(s)) { + return false; + } + } + if let Some(res_type) = &self.resource_type { + if let Some(actions) = identity.resources.get(res_type) { + if let Some(action) = &self.resource_action { + if !actions.contains(action) { + return false; + } + } + } else { + return false; + } + } + true + } + + pub fn has_restrictions(&self) -> bool { + !self.required_scopes.is_empty() + || self.required_scopes_any.is_some() + || self.resource_type.is_some() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationSpec { + pub name: String, + pub namespace: String, + pub op_type: OperationType, + pub input_schema: Value, + pub output_schema: Value, + pub access_control: AccessControl, +} + +impl OperationSpec { + pub fn path(&self) -> String { + format!("/{}", self.name) + } + + pub fn namespace_from_name(name: &str) -> String { + let trimmed = name.trim_start_matches('/'); + let parts: Vec<&str> = trimmed.split('/').collect(); + match parts.len() { + n if n >= 3 => parts[1].to_string(), + n if n >= 2 => parts[0].to_string(), + _ => String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_identity( + scopes: Vec, + resources: HashMap>, + ) -> crate::auth::Identity { + crate::auth::Identity { + id: "test".to_string(), + scopes, + resources, + } + } + + #[test] + fn access_control_allows_matching_scopes() { + let ac = AccessControl { + required_scopes: vec!["read".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let id = make_identity(vec!["read".to_string()], HashMap::new()); + assert!(ac.check(&id)); + } + + #[test] + fn access_control_rejects_missing_scopes() { + let ac = AccessControl { + required_scopes: vec!["admin".to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }; + let id = make_identity(vec!["read".to_string()], HashMap::new()); + assert!(!ac.check(&id)); + } + + #[test] + fn access_control_required_scopes_any_matches() { + let ac = AccessControl { + required_scopes: vec![], + required_scopes_any: Some(vec!["admin".to_string(), "read".to_string()]), + resource_type: None, + resource_action: None, + }; + let id = make_identity(vec!["read".to_string()], HashMap::new()); + assert!(ac.check(&id)); + } + + #[test] + fn access_control_required_scopes_any_rejects() { + let ac = AccessControl { + required_scopes: vec![], + required_scopes_any: Some(vec!["admin".to_string()]), + resource_type: None, + resource_action: None, + }; + let id = make_identity(vec!["read".to_string()], HashMap::new()); + assert!(!ac.check(&id)); + } + + #[test] + fn access_control_resource_check_matches() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["read".to_string()]); + let ac = AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + }; + let id = make_identity(vec![], resources); + assert!(ac.check(&id)); + } + + #[test] + fn access_control_resource_check_missing_resource_type() { + let ac = AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + }; + let id = make_identity(vec![], HashMap::new()); + assert!(!ac.check(&id)); + } + + #[test] + fn access_control_resource_check_missing_action() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["write".to_string()]); + let ac = AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + }; + let id = make_identity(vec![], resources); + assert!(!ac.check(&id)); + } + + #[test] + fn access_control_combined_scopes_and_resources() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["read".to_string()]); + let ac = AccessControl { + required_scopes: vec!["relay:connect".to_string()], + required_scopes_any: Some(vec!["admin".to_string()]), + resource_type: Some("service".to_string()), + resource_action: Some("read".to_string()), + }; + let id = make_identity( + vec!["relay:connect".to_string(), "admin".to_string()], + resources, + ); + assert!(ac.check(&id)); + } + + #[test] + fn operation_type_variants() { + assert_eq!(OperationType::Query, OperationType::Query); + assert_ne!(OperationType::Query, OperationType::Mutation); + assert_ne!(OperationType::Mutation, OperationType::Subscription); + } + + #[test] + fn operation_spec_namespace_from_name() { + assert_eq!(OperationSpec::namespace_from_name("/auth/verify"), "auth"); + assert_eq!(OperationSpec::namespace_from_name("/fs/readFile"), "fs"); + assert_eq!( + OperationSpec::namespace_from_name("/head/agent/chat"), + "agent" + ); + } + + #[test] + fn operation_spec_path() { + let spec = OperationSpec { + name: "auth/verify".to_string(), + namespace: "auth".to_string(), + op_type: OperationType::Query, + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + access_control: AccessControl { + required_scopes: vec![], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }, + }; + assert_eq!(spec.path(), "/auth/verify"); + } +} diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 5b27570..a4b1b49 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -52,6 +52,7 @@ //! ``` pub mod auth; +pub mod call; pub mod client; pub mod config; pub mod error; @@ -65,6 +66,10 @@ pub mod testutil; #[cfg(feature = "irpc")] pub use auth::{AuthProtocol, AuthResult, AuthServiceImpl}; pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; +pub use call::{ + AccessControl, CallError, Handler, OperationContext, OperationEnv, OperationRegistry, + OperationRegistryBuilder, OperationSpec, OperationType, ResponseEnvelope, +}; pub use client::channel_manager::{ChannelManager, ForwardRequest}; pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode}; pub use config::{