diff --git a/Cargo.lock b/Cargo.lock index 2849a148cf..8a3aa1e939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1218,6 +1218,7 @@ dependencies = [ "tokio-test", "url", "webbrowser", + "wiremock", ] [[package]] @@ -1246,6 +1247,7 @@ version = "0.0.0" dependencies = [ "actix", "borsh", + "bs58 0.5.1", "calimero-context-config", "calimero-context-primitives", "calimero-node-primitives", @@ -1258,12 +1260,14 @@ dependencies = [ "either", "eyre", "futures-util", + "hex", "memchr", "ouroboros", "prometheus-client", "rand 0.8.5", "serde", "serde_json", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tracing", @@ -1303,6 +1307,7 @@ dependencies = [ "calimero-primitives", "calimero-store", "calimero-utils-actix", + "ed25519-dalek", "eyre", "futures-util", "hex", @@ -2701,6 +2706,24 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "decoded-char" version = "0.1.1" @@ -5970,6 +5993,7 @@ version = "0.1.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", + "bs58 0.5.1", "calimero-blobstore", "calimero-build-utils", "calimero-config", @@ -6828,6 +6852,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -11804,6 +11838,29 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/README.mdx b/README.mdx index 62fccbd03e..ef46f90a13 100644 --- a/README.mdx +++ b/README.mdx @@ -28,6 +28,7 @@ Calimero Network is a framework for building distributed, peer-to-peer applicati - **[Storage Documentation](crates/storage/README.md)** - CRDT types, merge semantics - **[SDK Documentation](crates/sdk/README.md)** - API reference, examples - **[Network Documentation](crates/network/README.md)** - P2P protocols, configuration +- **[Context Group Management](docs/context-management/GROUP-FEATURE-OVERVIEW.md)** - Groups, permissions, upgrades, aliases **🔧 Guides:** - **[Integration Guide](crates/node/readme/integration-guide.md)** - Building applications diff --git a/crates/auth/src/auth/service.rs b/crates/auth/src/auth/service.rs index f375a5d90a..31f173a256 100644 --- a/crates/auth/src/auth/service.rs +++ b/crates/auth/src/auth/service.rs @@ -59,6 +59,19 @@ impl AuthService { self.token_manager.verify_token_from_headers(headers).await } + /// Return the `public_key` field stored for `key_id`, if any. + /// + /// **Security note:** `key_id` MUST come from a previously verified JWT token + /// (i.e. returned by `verify_token_from_headers`). Callers must not pass + /// untrusted or user-supplied values — doing so would allow arbitrary + /// identity lookups. + /// + /// Used by the server auth guard to inject the authenticated identity into + /// request extensions so handlers can use it as the effective requester. + pub async fn get_key_public_key(&self, key_id: &str) -> Result, AuthError> { + self.token_manager.get_public_key_for_key_id(key_id).await + } + /// Authenticate a token request /// /// This method authenticates the user using the provided token request diff --git a/crates/auth/src/auth/token/jwt.rs b/crates/auth/src/auth/token/jwt.rs index 317cf7f116..5f1b1960bf 100644 --- a/crates/auth/src/auth/token/jwt.rs +++ b/crates/auth/src/auth/token/jwt.rs @@ -419,6 +419,19 @@ impl TokenManager { Ok(()) } + /// Return the `public_key` field stored for a given `key_id`, if any. + pub async fn get_public_key_for_key_id( + &self, + key_id: &str, + ) -> Result, AuthError> { + let key = self + .key_manager + .get_key(key_id) + .await + .map_err(|e| AuthError::StorageError(e.to_string()))?; + Ok(key.and_then(|k| k.public_key)) + } + /// Refresh a token pair using a refresh token /// /// This method verifies the refresh token and generates new tokens based on the key type. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index df316c4eac..99e9f5885c 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -29,6 +29,7 @@ calimero-context-config.workspace = true [dev-dependencies] tokio-test.workspace = true +wiremock = "0.6" [features] default = [] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index b83aae055c..cfb583d920 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -16,10 +16,11 @@ use calimero_primitives::identity::PublicKey; use calimero_server_primitives::admin::{ AliasKind, CreateAliasRequest, CreateAliasResponse, CreateApplicationIdAlias, CreateContextIdAlias, CreateContextIdentityAlias, CreateContextRequest, CreateContextResponse, - DeleteAliasResponse, DeleteContextResponse, GenerateContextIdentityResponse, - GetApplicationResponse, GetContextClientKeysResponse, GetContextIdentitiesResponse, - GetContextResponse, GetContextStorageResponse, GetContextsResponse, GetLatestVersionResponse, - GetPeersCountResponse, GetProposalApproversResponse, GetProposalResponse, GetProposalsResponse, + DeleteAliasResponse, DeleteContextApiRequest, DeleteContextResponse, + GenerateContextIdentityResponse, GetApplicationResponse, GetContextClientKeysResponse, + GetContextIdentitiesResponse, GetContextResponse, GetContextStorageResponse, + GetContextsResponse, GetLatestVersionResponse, GetPeersCountResponse, + GetProposalApproversResponse, GetProposalResponse, GetProposalsResponse, GrantPermissionResponse, InstallApplicationRequest, InstallApplicationResponse, InstallDevApplicationRequest, InviteSpecializedNodeRequest, InviteSpecializedNodeResponse, InviteToContextOpenInvitationRequest, InviteToContextOpenInvitationResponse, @@ -40,6 +41,8 @@ use url::Url; use crate::connection::ConnectionInfo; use crate::traits::{ClientAuthenticator, ClientStorage}; +mod group; + pub trait UrlFragment: ScopedAlias + AliasKind { const KIND: &'static str; @@ -487,10 +490,17 @@ where Ok(response) } - pub async fn delete_context(&self, context_id: &ContextId) -> Result { + pub async fn delete_context( + &self, + context_id: &ContextId, + requester: Option, + ) -> Result { let response = self .connection - .delete(&format!("admin-api/contexts/{context_id}")) + .delete_with_body( + &format!("admin-api/contexts/{context_id}"), + DeleteContextApiRequest { requester }, + ) .await?; Ok(response) } diff --git a/crates/client/src/client/group.rs b/crates/client/src/client/group.rs new file mode 100644 index 0000000000..9112691cb6 --- /dev/null +++ b/crates/client/src/client/group.rs @@ -0,0 +1,400 @@ +//! Group API methods for the Calimero client. + +use eyre::Result; + +use calimero_server_primitives::admin::AddGroupMembersApiRequest; +use calimero_server_primitives::admin::AddGroupMembersApiResponse; +use calimero_server_primitives::admin::CreateGroupApiRequest; +use calimero_server_primitives::admin::CreateGroupApiResponse; +use calimero_server_primitives::admin::CreateGroupInvitationApiRequest; +use calimero_server_primitives::admin::CreateGroupInvitationApiResponse; +use calimero_server_primitives::admin::DeleteGroupApiRequest; +use calimero_server_primitives::admin::DeleteGroupApiResponse; +use calimero_server_primitives::admin::DetachContextFromGroupApiRequest; +use calimero_server_primitives::admin::DetachContextFromGroupApiResponse; +use calimero_server_primitives::admin::GetContextAllowlistApiResponse; +use calimero_server_primitives::admin::GetContextVisibilityApiResponse; +use calimero_server_primitives::admin::GetGroupUpgradeStatusApiResponse; +use calimero_server_primitives::admin::GetMemberCapabilitiesApiResponse; +use calimero_server_primitives::admin::GroupInfoApiResponse; +use calimero_server_primitives::admin::JoinGroupApiRequest; +use calimero_server_primitives::admin::JoinGroupApiResponse; +use calimero_server_primitives::admin::JoinGroupContextApiRequest; +use calimero_server_primitives::admin::JoinGroupContextApiResponse; +use calimero_server_primitives::admin::ListAllGroupsApiResponse; +use calimero_server_primitives::admin::ListGroupContextsApiResponse; +use calimero_server_primitives::admin::ListGroupMembersApiResponse; +use calimero_server_primitives::admin::ManageContextAllowlistApiRequest; +use calimero_server_primitives::admin::ManageContextAllowlistApiResponse; +use calimero_server_primitives::admin::RegisterGroupSigningKeyApiRequest; +use calimero_server_primitives::admin::RegisterGroupSigningKeyApiResponse; +use calimero_server_primitives::admin::RemoveGroupMembersApiRequest; +use calimero_server_primitives::admin::RemoveGroupMembersApiResponse; +use calimero_server_primitives::admin::RetryGroupUpgradeApiRequest; +use calimero_server_primitives::admin::SetContextVisibilityApiRequest; +use calimero_server_primitives::admin::SetContextVisibilityApiResponse; +use calimero_server_primitives::admin::SetDefaultCapabilitiesApiRequest; +use calimero_server_primitives::admin::SetDefaultCapabilitiesApiResponse; +use calimero_server_primitives::admin::SetDefaultVisibilityApiRequest; +use calimero_server_primitives::admin::SetDefaultVisibilityApiResponse; +use calimero_server_primitives::admin::SetMemberCapabilitiesApiRequest; +use calimero_server_primitives::admin::SetMemberCapabilitiesApiResponse; +use calimero_server_primitives::admin::SyncGroupApiRequest; +use calimero_server_primitives::admin::SyncGroupApiResponse; +use calimero_server_primitives::admin::UpdateGroupSettingsApiRequest; +use calimero_server_primitives::admin::UpdateGroupSettingsApiResponse; +use calimero_server_primitives::admin::UpdateMemberRoleApiRequest; +use calimero_server_primitives::admin::UpdateMemberRoleApiResponse; +use calimero_server_primitives::admin::UpgradeGroupApiRequest; +use calimero_server_primitives::admin::UpgradeGroupApiResponse; + +use crate::traits::ClientAuthenticator; +use crate::traits::ClientStorage; + +impl super::Client +where + A: ClientAuthenticator + Clone + Send + Sync, + S: ClientStorage + Clone + Send + Sync, +{ + pub async fn list_groups(&self) -> Result { + let response = self.connection.get("admin-api/groups").await?; + Ok(response) + } + + pub async fn create_group( + &self, + request: CreateGroupApiRequest, + ) -> Result { + let response = self.connection.post("admin-api/groups", request).await?; + Ok(response) + } + + pub async fn get_group_info(&self, group_id: &str) -> Result { + let response = self + .connection + .get(&format!("admin-api/groups/{group_id}")) + .await?; + Ok(response) + } + + pub async fn delete_group( + &self, + group_id: &str, + request: DeleteGroupApiRequest, + ) -> Result { + let response = self + .connection + .delete_with_body(&format!("admin-api/groups/{group_id}"), request) + .await?; + Ok(response) + } + + pub async fn update_group_settings( + &self, + group_id: &str, + request: UpdateGroupSettingsApiRequest, + ) -> Result { + let response = self + .connection + .patch(&format!("admin-api/groups/{group_id}"), request) + .await?; + Ok(response) + } + + pub async fn list_group_members(&self, group_id: &str) -> Result { + let response = self + .connection + .get(&format!("admin-api/groups/{group_id}/members")) + .await?; + Ok(response) + } + + pub async fn add_group_members( + &self, + group_id: &str, + request: AddGroupMembersApiRequest, + ) -> Result { + let response = self + .connection + .post(&format!("admin-api/groups/{group_id}/members"), request) + .await?; + Ok(response) + } + + pub async fn remove_group_members( + &self, + group_id: &str, + request: RemoveGroupMembersApiRequest, + ) -> Result { + let response = self + .connection + .post( + &format!("admin-api/groups/{group_id}/members/remove"), + request, + ) + .await?; + Ok(response) + } + + pub async fn update_member_role( + &self, + group_id: &str, + identity_hex: &str, + request: UpdateMemberRoleApiRequest, + ) -> Result { + let response = self + .connection + .put_json( + &format!("admin-api/groups/{group_id}/members/{identity_hex}/role"), + request, + ) + .await?; + Ok(response) + } + + pub async fn list_group_contexts( + &self, + group_id: &str, + ) -> Result { + let response = self + .connection + .get(&format!("admin-api/groups/{group_id}/contexts")) + .await?; + Ok(response) + } + + pub async fn detach_context_from_group( + &self, + group_id: &str, + context_id: &str, + request: DetachContextFromGroupApiRequest, + ) -> Result { + let response = self + .connection + .post( + &format!("admin-api/groups/{group_id}/contexts/{context_id}/remove"), + request, + ) + .await?; + Ok(response) + } + + pub async fn create_group_invitation( + &self, + group_id: &str, + request: CreateGroupInvitationApiRequest, + ) -> Result { + let response = self + .connection + .post(&format!("admin-api/groups/{group_id}/invite"), request) + .await?; + Ok(response) + } + + pub async fn join_group(&self, request: JoinGroupApiRequest) -> Result { + let response = self + .connection + .post("admin-api/groups/join", request) + .await?; + Ok(response) + } + + pub async fn register_group_signing_key( + &self, + group_id: &str, + request: RegisterGroupSigningKeyApiRequest, + ) -> Result { + let response = self + .connection + .post(&format!("admin-api/groups/{group_id}/signing-key"), request) + .await?; + Ok(response) + } + + pub async fn upgrade_group( + &self, + group_id: &str, + request: UpgradeGroupApiRequest, + ) -> Result { + let response = self + .connection + .post(&format!("admin-api/groups/{group_id}/upgrade"), request) + .await?; + Ok(response) + } + + pub async fn get_group_upgrade_status( + &self, + group_id: &str, + ) -> Result { + let response = self + .connection + .get(&format!("admin-api/groups/{group_id}/upgrade/status")) + .await?; + Ok(response) + } + + pub async fn retry_group_upgrade( + &self, + group_id: &str, + request: RetryGroupUpgradeApiRequest, + ) -> Result { + let response = self + .connection + .post( + &format!("admin-api/groups/{group_id}/upgrade/retry"), + request, + ) + .await?; + Ok(response) + } + + pub async fn sync_group( + &self, + group_id: &str, + request: SyncGroupApiRequest, + ) -> Result { + let response = self + .connection + .post(&format!("admin-api/groups/{group_id}/sync"), request) + .await?; + Ok(response) + } + + pub async fn join_group_context( + &self, + group_id: &str, + request: JoinGroupContextApiRequest, + ) -> Result { + let response = self + .connection + .post( + &format!("admin-api/groups/{group_id}/join-context"), + request, + ) + .await?; + Ok(response) + } + + // ---- Group Permissions ---- + + pub async fn set_member_capabilities( + &self, + group_id: &str, + identity_hex: &str, + request: SetMemberCapabilitiesApiRequest, + ) -> Result { + let response = self + .connection + .put_json( + &format!("admin-api/groups/{group_id}/members/{identity_hex}/capabilities"), + request, + ) + .await?; + Ok(response) + } + + pub async fn get_member_capabilities( + &self, + group_id: &str, + identity_hex: &str, + ) -> Result { + let response = self + .connection + .get(&format!( + "admin-api/groups/{group_id}/members/{identity_hex}/capabilities" + )) + .await?; + Ok(response) + } + + pub async fn set_context_visibility( + &self, + group_id: &str, + context_id: &str, + request: SetContextVisibilityApiRequest, + ) -> Result { + let response = self + .connection + .put_json( + &format!("admin-api/groups/{group_id}/contexts/{context_id}/visibility"), + request, + ) + .await?; + Ok(response) + } + + pub async fn get_context_visibility( + &self, + group_id: &str, + context_id: &str, + ) -> Result { + let response = self + .connection + .get(&format!( + "admin-api/groups/{group_id}/contexts/{context_id}/visibility" + )) + .await?; + Ok(response) + } + + pub async fn manage_context_allowlist( + &self, + group_id: &str, + context_id: &str, + request: ManageContextAllowlistApiRequest, + ) -> Result { + let response = self + .connection + .post( + &format!("admin-api/groups/{group_id}/contexts/{context_id}/allowlist"), + request, + ) + .await?; + Ok(response) + } + + pub async fn get_context_allowlist( + &self, + group_id: &str, + context_id: &str, + ) -> Result { + let response = self + .connection + .get(&format!( + "admin-api/groups/{group_id}/contexts/{context_id}/allowlist" + )) + .await?; + Ok(response) + } + + pub async fn set_default_capabilities( + &self, + group_id: &str, + request: SetDefaultCapabilitiesApiRequest, + ) -> Result { + let response = self + .connection + .put_json( + &format!("admin-api/groups/{group_id}/settings/default-capabilities"), + request, + ) + .await?; + Ok(response) + } + + pub async fn set_default_visibility( + &self, + group_id: &str, + request: SetDefaultVisibilityApiRequest, + ) -> Result { + let response = self + .connection + .put_json( + &format!("admin-api/groups/{group_id}/settings/default-visibility"), + request, + ) + .await?; + Ok(response) + } +} diff --git a/crates/client/src/connection.rs b/crates/client/src/connection.rs index a43877b877..82a8d35846 100644 --- a/crates/client/src/connection.rs +++ b/crates/client/src/connection.rs @@ -29,6 +29,9 @@ enum RequestType { Get, Post, Delete, + Patch, + Put, + DeleteWithBody, } #[derive(Debug)] @@ -97,6 +100,31 @@ where self.request(RequestType::Delete, path, None::<()>).await } + pub async fn delete_with_body(&self, path: &str, body: I) -> Result + where + I: Serialize, + O: DeserializeOwned, + { + self.request(RequestType::DeleteWithBody, path, Some(body)) + .await + } + + pub async fn patch(&self, path: &str, body: I) -> Result + where + I: Serialize, + O: DeserializeOwned, + { + self.request(RequestType::Patch, path, Some(body)).await + } + + pub async fn put_json(&self, path: &str, body: I) -> Result + where + I: Serialize, + O: DeserializeOwned, + { + self.request(RequestType::Put, path, Some(body)).await + } + pub async fn put_binary(&self, path: &str, data: Vec) -> Result { let mut url = self.api_url.clone(); @@ -296,6 +324,9 @@ where RequestType::Get => self.client.get(url.clone()), RequestType::Post => self.client.post(url.clone()).json(&body), RequestType::Delete => self.client.delete(url.clone()), + RequestType::Patch => self.client.patch(url.clone()).json(&body), + RequestType::Put => self.client.put(url.clone()).json(&body), + RequestType::DeleteWithBody => self.client.delete(url.clone()).json(&body), }; if let Some(ref auth_header) = auth_header { @@ -366,7 +397,13 @@ where } if !response.status().is_success() { - bail!("Request failed with status: {}", response.status()); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&body) + .ok() + .and_then(|v| v["error"].as_str().map(str::to_owned)) + .unwrap_or_else(|| format!("Request failed with status: {status}")); + bail!("{}", msg); } return Ok(response); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 13768d73eb..fd78a8d732 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -35,5 +35,8 @@ pub use url::Url; /// Current version of the client library pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +#[cfg(test)] +mod tests; + #[cfg(test)] use tokio_test as _; diff --git a/crates/client/src/tests.rs b/crates/client/src/tests.rs new file mode 100644 index 0000000000..f4b323c87e --- /dev/null +++ b/crates/client/src/tests.rs @@ -0,0 +1,843 @@ +//! Unit tests for group API client methods. +//! +//! Each test verifies that the client fires the correct HTTP verb and URL +//! path, and that the response body is correctly deserialized. +//! +//! Auth is bypassed by setting `node_name: None` in `ConnectionInfo`, which +//! causes the auth path to be skipped entirely — `NoopAuth` and `NoopStorage` +//! are never actually called. + +use async_trait::async_trait; +use eyre::Result; +use url::Url; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::client::Client; +use crate::connection::ConnectionInfo; +use crate::storage::JwtToken; +use crate::traits::ClientAuthenticator; +use crate::traits::ClientStorage; + +use calimero_context_config::types::SignedGroupOpenInvitation; +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::ContextId; +use calimero_primitives::context::GroupMemberRole; +use calimero_primitives::context::UpgradePolicy; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::AddGroupMembersApiRequest; +use calimero_server_primitives::admin::CreateGroupApiRequest; +use calimero_server_primitives::admin::CreateGroupInvitationApiRequest; +use calimero_server_primitives::admin::DeleteGroupApiRequest; +use calimero_server_primitives::admin::DetachContextFromGroupApiRequest; +use calimero_server_primitives::admin::GroupMemberApiInput; +use calimero_server_primitives::admin::JoinGroupApiRequest; +use calimero_server_primitives::admin::JoinGroupContextApiRequest; +use calimero_server_primitives::admin::ManageContextAllowlistApiRequest; +use calimero_server_primitives::admin::RegisterGroupSigningKeyApiRequest; +use calimero_server_primitives::admin::RemoveGroupMembersApiRequest; +use calimero_server_primitives::admin::RetryGroupUpgradeApiRequest; +use calimero_server_primitives::admin::SetContextVisibilityApiRequest; +use calimero_server_primitives::admin::SetDefaultCapabilitiesApiRequest; +use calimero_server_primitives::admin::SetDefaultVisibilityApiRequest; +use calimero_server_primitives::admin::SetMemberCapabilitiesApiRequest; +use calimero_server_primitives::admin::SyncGroupApiRequest; +use calimero_server_primitives::admin::UpdateGroupSettingsApiRequest; +use calimero_server_primitives::admin::UpdateMemberRoleApiRequest; +use calimero_server_primitives::admin::UpgradeGroupApiRequest; + +/// Fixed test group ID used across tests. +const GID: &str = "test-group-id"; + +/// Fixed test context ID used in multi-segment path tests. +const CID: &str = "test-ctx-id"; + +/// Fixed test identity hex used in member path segments. +const IDENT: &str = "test-ident"; + +/// Base58 encoding of `[0u8; 32]` — used wherever a Hash-backed type is +/// required in JSON response bodies. +const ZERO_BS58: &str = "11111111111111111111111111111111"; + +// ---- Stub impls (node_name=None means these are never called) ---- + +#[derive(Clone)] +struct NoopAuth; + +#[derive(Clone)] +struct NoopStorage; + +#[async_trait] +impl ClientAuthenticator for NoopAuth { + async fn authenticate(&self, _: &Url) -> Result { + unimplemented!("NoopAuth is never called when node_name=None") + } + + async fn refresh_tokens(&self, _: &str) -> Result { + unimplemented!("NoopAuth is never called when node_name=None") + } + + async fn handle_auth_failure(&self, _: &Url) -> Result { + unimplemented!("NoopAuth is never called when node_name=None") + } + + async fn check_auth_required(&self, _: &Url) -> Result { + unimplemented!("NoopAuth is never called when node_name=None") + } + + fn get_auth_method(&self) -> &'static str { + "noop" + } +} + +#[async_trait] +impl ClientStorage for NoopStorage { + async fn load_tokens(&self, _: &str) -> Result> { + unimplemented!("NoopStorage is never called when node_name=None") + } + + async fn save_tokens(&self, _: &str, _: &JwtToken) -> Result<()> { + unimplemented!("NoopStorage is never called when node_name=None") + } +} + +fn make_client(base_url: &Url) -> Client { + let conn = ConnectionInfo::new(base_url.clone(), None, NoopAuth, NoopStorage); + Client::new(conn).unwrap() +} + +// ---- Group CRUD ---- + +#[tokio::test] +async fn list_groups() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/admin-api/groups")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.list_groups().await.unwrap(); + + assert!(resp.data.is_empty()); +} + +#[tokio::test] +async fn create_group() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/admin-api/groups")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"groupId": GID} + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .create_group(CreateGroupApiRequest { + group_id: None, + app_key: None, + application_id: ApplicationId::from([0u8; 32]), + upgrade_policy: UpgradePolicy::Automatic, + alias: None, + }) + .await + .unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +#[tokio::test] +async fn get_group_info() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/admin-api/groups/{GID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "groupId": GID, + "appKey": "testkey", + "targetApplicationId": ZERO_BS58, + "upgradePolicy": "Automatic", + "memberCount": 0, + "contextCount": 0, + "activeUpgrade": null, + "defaultCapabilities": 0, + "defaultVisibility": "open" + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.get_group_info(GID).await.unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +#[tokio::test] +async fn delete_group() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path(format!("/admin-api/groups/{GID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"isDeleted": true} + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .delete_group(GID, DeleteGroupApiRequest { requester: None }) + .await + .unwrap(); + + assert!(resp.data.is_deleted); +} + +#[tokio::test] +async fn update_group_settings() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path(format!("/admin-api/groups/{GID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .update_group_settings( + GID, + UpdateGroupSettingsApiRequest { + requester: None, + upgrade_policy: UpgradePolicy::Automatic, + }, + ) + .await + .unwrap(); +} + +// ---- Members ---- + +#[tokio::test] +async fn list_group_members() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/admin-api/groups/{GID}/members"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.list_group_members(GID).await.unwrap(); + + assert!(resp.data.is_empty()); +} + +#[tokio::test] +async fn add_group_members() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/members"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .add_group_members( + GID, + AddGroupMembersApiRequest { + members: vec![GroupMemberApiInput { + identity: PublicKey::from([0u8; 32]), + role: GroupMemberRole::Member, + }], + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn remove_group_members() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/members/remove"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .remove_group_members( + GID, + RemoveGroupMembersApiRequest { + members: vec![PublicKey::from([0u8; 32])], + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn update_member_role() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path(format!( + "/admin-api/groups/{GID}/members/{IDENT}/role" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .update_member_role( + GID, + IDENT, + UpdateMemberRoleApiRequest { + role: GroupMemberRole::Admin, + requester: None, + }, + ) + .await + .unwrap(); +} + +// ---- Contexts ---- + +#[tokio::test] +async fn list_group_contexts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/admin-api/groups/{GID}/contexts"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.list_group_contexts(GID).await.unwrap(); + + assert!(resp.data.is_empty()); +} + +#[tokio::test] +async fn detach_context_from_group() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!( + "/admin-api/groups/{GID}/contexts/{CID}/remove" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .detach_context_from_group( + GID, + CID, + DetachContextFromGroupApiRequest { requester: None }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn join_group_context() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/join-context"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "contextId": ZERO_BS58, + "memberPublicKey": ZERO_BS58 + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .join_group_context( + GID, + JoinGroupContextApiRequest { + context_id: ContextId::from([0u8; 32]), + }, + ) + .await + .unwrap(); + + assert_eq!(resp.data.member_public_key, PublicKey::from([0u8; 32])); +} + +// ---- Invitations & Joining ---- + +#[tokio::test] +async fn create_group_invitation() { + // `SignedGroupOpenInvitation` fields are snake_case (no rename_all on that + // struct), so the mock body uses the raw field names from the types crate. + let zeros: Vec = vec![0; 32]; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/invite"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "invitation": { + "invitation": { + "inviter_identity": zeros, + "group_id": zeros, + "expiration_height": 0u64, + "secret_salt": zeros, + "protocol": "near", + "network": "testnet", + "contract_id": "contract.testnet" + }, + "inviter_signature": "testsig" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .create_group_invitation( + GID, + CreateGroupInvitationApiRequest { + requester: None, + expiration_block_height: None, + }, + ) + .await + .unwrap(); + + assert_eq!(resp.data.invitation.inviter_signature, "testsig"); +} + +#[tokio::test] +async fn join_group() { + // Build the invitation by deserializing from JSON so we avoid private-field + // construction of the inner Identity/SignerId newtypes. + let zeros: Vec = vec![0; 32]; + let invitation: SignedGroupOpenInvitation = serde_json::from_value(serde_json::json!({ + "invitation": { + "inviter_identity": zeros, + "group_id": zeros, + "expiration_height": 0u64, + "secret_salt": zeros, + "protocol": "near", + "network": "testnet", + "contract_id": "contract.testnet" + }, + "inviter_signature": "testsig" + })) + .unwrap(); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/admin-api/groups/join")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "groupId": GID, + "memberIdentity": ZERO_BS58 + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .join_group(JoinGroupApiRequest { + invitation, + group_alias: None, + }) + .await + .unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +// ---- Upgrade ---- + +#[tokio::test] +async fn upgrade_group() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/upgrade"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "groupId": GID, + "status": "pending", + "total": null, + "completed": null, + "failed": null + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .upgrade_group( + GID, + UpgradeGroupApiRequest { + target_application_id: ApplicationId::from([0u8; 32]), + requester: None, + migrate_method: None, + }, + ) + .await + .unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +#[tokio::test] +async fn get_group_upgrade_status() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/admin-api/groups/{GID}/upgrade/status"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null}))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.get_group_upgrade_status(GID).await.unwrap(); + + assert!(resp.data.is_none()); +} + +#[tokio::test] +async fn retry_group_upgrade() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/upgrade/retry"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "groupId": GID, + "status": "pending", + "total": null, + "completed": null, + "failed": null + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .retry_group_upgrade(GID, RetryGroupUpgradeApiRequest { requester: None }) + .await + .unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +// ---- Sync & Signing Key ---- + +#[tokio::test] +async fn sync_group() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/sync"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "groupId": GID, + "appKey": "testkey", + "targetApplicationId": ZERO_BS58, + "memberCount": 0, + "contextCount": 0 + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .sync_group( + GID, + SyncGroupApiRequest { + requester: None, + protocol: None, + network_id: None, + contract_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(resp.data.group_id, GID); +} + +#[tokio::test] +async fn register_group_signing_key() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/admin-api/groups/{GID}/signing-key"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"publicKey": ZERO_BS58} + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client + .register_group_signing_key( + GID, + RegisterGroupSigningKeyApiRequest { + signing_key: "testkey".to_string(), + }, + ) + .await + .unwrap(); + + assert_eq!(resp.data.public_key, PublicKey::from([0u8; 32])); +} + +// ---- Member Capabilities & Visibility ---- + +#[tokio::test] +async fn set_member_capabilities() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path(format!( + "/admin-api/groups/{GID}/members/{IDENT}/capabilities" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .set_member_capabilities( + GID, + IDENT, + SetMemberCapabilitiesApiRequest { + capabilities: 0, + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn get_member_capabilities() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/admin-api/groups/{GID}/members/{IDENT}/capabilities" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"capabilities": 42} + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.get_member_capabilities(GID, IDENT).await.unwrap(); + + assert_eq!(resp.data.capabilities, 42); +} + +#[tokio::test] +async fn set_context_visibility() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path(format!( + "/admin-api/groups/{GID}/contexts/{CID}/visibility" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .set_context_visibility( + GID, + CID, + SetContextVisibilityApiRequest { + mode: "open".to_string(), + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn get_context_visibility() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/admin-api/groups/{GID}/contexts/{CID}/visibility" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "mode": "open", + "creator": ZERO_BS58 + } + }))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.get_context_visibility(GID, CID).await.unwrap(); + + assert_eq!(resp.data.mode, "open"); +} + +// ---- Allowlist ---- + +#[tokio::test] +async fn manage_context_allowlist() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!( + "/admin-api/groups/{GID}/contexts/{CID}/allowlist" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .manage_context_allowlist( + GID, + CID, + ManageContextAllowlistApiRequest { + add: vec![], + remove: vec![], + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn get_context_allowlist() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/admin-api/groups/{GID}/contexts/{CID}/allowlist" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let resp = client.get_context_allowlist(GID, CID).await.unwrap(); + + assert!(resp.data.is_empty()); +} + +// ---- Group Settings ---- + +#[tokio::test] +async fn set_default_capabilities() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path(format!( + "/admin-api/groups/{GID}/settings/default-capabilities" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .set_default_capabilities( + GID, + SetDefaultCapabilitiesApiRequest { + default_capabilities: 0, + requester: None, + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn set_default_visibility() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path(format!( + "/admin-api/groups/{GID}/settings/default-visibility" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + client + .set_default_visibility( + GID, + SetDefaultVisibilityApiRequest { + default_visibility: "open".to_string(), + requester: None, + }, + ) + .await + .unwrap(); +} + +// ---- Error handling ---- + +#[tokio::test] +async fn create_group_returns_err_on_server_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/admin-api/groups")) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(serde_json::json!({"error": "internal server error"})), + ) + .expect(1) + .mount(&server) + .await; + + let client = make_client(&Url::parse(&server.uri()).unwrap()); + let result = client + .create_group(CreateGroupApiRequest { + group_id: None, + app_key: None, + application_id: ApplicationId::from([0u8; 32]), + upgrade_policy: UpgradePolicy::Automatic, + alias: None, + }) + .await; + + assert!(result.is_err()); +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 17df18d59c..3c0f0ab928 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -16,18 +16,37 @@ use url::Url; use mero_auth::config::AuthConfig; -pub use calimero_node_primitives::NodeMode; +pub use calimero_node_primitives::{GroupIdentityConfig, NodeMode}; pub const CONFIG_FILE: &str = "config.toml"; +/// Node identity configuration containing the libp2p network identity and +/// an optional ed25519 group identity for group contract operations. +/// +/// Serialized as the `[identity]` TOML section with `peer_id`, `keypair`, +/// and an optional `[identity.group]` sub-table. +#[derive(Debug)] +pub struct IdentityConfig { + /// Libp2p network identity keypair. + pub keypair: libp2p_identity::Keypair, + /// Group identity for signing group contract mutations. + pub group: Option, +} + +impl IdentityConfig { + fn generate_default() -> Self { + Self { + keypair: libp2p_identity::Keypair::generate_ed25519(), + group: None, + } + } +} + #[derive(Debug, Deserialize, Serialize)] #[non_exhaustive] pub struct ConfigFile { - #[serde( - with = "serde_identity", - default = "libp2p_identity::Keypair::generate_ed25519" - )] - pub identity: libp2p_identity::Keypair, + #[serde(with = "serde_identity", default = "IdentityConfig::generate_default")] + pub identity: IdentityConfig, #[serde(default)] pub mode: NodeMode, @@ -436,7 +455,7 @@ impl BlobStoreConfig { impl ConfigFile { #[must_use] pub const fn new( - identity: libp2p_identity::Keypair, + identity: IdentityConfig, mode: NodeMode, network: NetworkConfig, sync: SyncConfig, @@ -516,30 +535,44 @@ pub mod serde_identity { use serde::ser::{self, SerializeMap}; use serde::{Deserializer, Serializer}; - pub fn serialize(key: &Keypair, serializer: S) -> Result + use super::{GroupIdentityConfig, IdentityConfig}; + + pub fn serialize(config: &IdentityConfig, serializer: S) -> Result where S: Serializer, { - let mut keypair = serializer.serialize_map(Some(2))?; - keypair.serialize_entry("peer_id", &key.public().to_peer_id().to_base58())?; - keypair.serialize_entry( + let entry_count = if config.group.is_some() { 3 } else { 2 }; + let mut map = serializer.serialize_map(Some(entry_count))?; + map.serialize_entry("peer_id", &config.keypair.public().to_peer_id().to_base58())?; + map.serialize_entry( "keypair", - &bs58::encode(&key.to_protobuf_encoding().map_err(ser::Error::custom)?).into_string(), + &bs58::encode( + &config + .keypair + .to_protobuf_encoding() + .map_err(ser::Error::custom)?, + ) + .into_string(), )?; - keypair.end() + if let Some(group) = &config.group { + map.serialize_entry("group", group)?; + } + map.end() } - pub fn deserialize<'de, D>(deserializer: D) -> Result + pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { struct IdentityVisitor; impl<'de> de::Visitor<'de> for IdentityVisitor { - type Value = Keypair; + type Value = IdentityConfig; fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - formatter.write_str("an identity") + formatter.write_str( + "an identity configuration with peer_id, keypair, and optional group", + ) } fn visit_map(self, mut map: A) -> Result @@ -548,11 +581,13 @@ pub mod serde_identity { { let mut peer_id = None::; let mut priv_key = None::; + let mut group = None::; while let Some(key) = map.next_key::()? { match key.as_str() { "peer_id" => peer_id = Some(map.next_value()?), "keypair" => priv_key = Some(map.next_value()?), + "group" => group = Some(map.next_value()?), _ => { drop(map.next_value::()); } @@ -573,11 +608,15 @@ pub mod serde_identity { return Err(de::Error::custom("Peer ID does not match public key")); } - Ok(keypair) + Ok(IdentityConfig { keypair, group }) } } - deserializer.deserialize_struct("Keypair", &["peer_id", "keypair"], IdentityVisitor) + deserializer.deserialize_struct( + "IdentityConfig", + &["peer_id", "keypair", "group"], + IdentityVisitor, + ) } } diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index 8cd54a32f4..51252423a3 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -11,14 +11,17 @@ publish = true [dependencies] actix.workspace = true borsh.workspace = true +bs58.workspace = true either.workspace = true eyre.workspace = true futures-util.workspace = true +hex.workspace = true memchr.workspace = true ouroboros.workspace = true prometheus-client.workspace = true rand.workspace = true serde.workspace = true +sha2.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["sync", "macros"] } diff --git a/crates/context/config/src/client/env/config/mutate.rs b/crates/context/config/src/client/env/config/mutate.rs index 69dc49098d..813f7dfd47 100644 --- a/crates/context/config/src/client/env/config/mutate.rs +++ b/crates/context/config/src/client/env/config/mutate.rs @@ -8,10 +8,14 @@ use super::requests::{ use crate::client::env::utils; use crate::client::transport::Transport; use crate::client::{CallClient, ClientError, Operation}; +use crate::repr::Repr; use crate::types::{ - Application, BlockHeight, Capability, ContextId, ContextIdentity, SignedRevealPayload, + AppKey, Application, BlockHeight, Capability, ContextGroupId, ContextId, ContextIdentity, + SignedGroupRevealPayload, SignedRevealPayload, SignerId, +}; +use crate::{ + ContextRequest, ContextRequestKind, GroupRequest, GroupRequestKind, RequestKind, VisibilityMode, }; -use crate::{ContextRequest, ContextRequestKind, RequestKind}; #[derive(Debug)] pub struct ContextConfigMutate<'a, T> { @@ -188,6 +192,256 @@ impl<'a, T> ContextConfigMutate<'a, T> { }), } } + + pub fn create_group( + self, + group_id: ContextGroupId, + app_key: AppKey, + target_application: Application<'a>, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::Create { + app_key: Repr::new(app_key), + target_application, + }, + )), + } + } + + pub fn delete_group(self, group_id: ContextGroupId) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::Delete, + )), + } + } + + pub fn add_group_members( + self, + group_id: ContextGroupId, + members: &'a [SignerId], + ) -> ContextConfigMutateRequest<'a, T> { + let members = Repr::slice_from_inner(members); + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::AddMembers { + members: members.into(), + }, + )), + } + } + + pub fn remove_group_members( + self, + group_id: ContextGroupId, + members: &'a [SignerId], + ) -> ContextConfigMutateRequest<'a, T> { + let members = Repr::slice_from_inner(members); + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::RemoveMembers { + members: members.into(), + }, + )), + } + } + + pub fn register_context_in_group( + self, + group_id: ContextGroupId, + context_id: ContextId, + visibility_mode: Option, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::RegisterContext { + context_id: Repr::new(context_id), + visibility_mode, + }, + )), + } + } + + pub fn unregister_context_from_group( + self, + group_id: ContextGroupId, + context_id: ContextId, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::UnregisterContext { + context_id: Repr::new(context_id), + }, + )), + } + } + + pub fn set_group_target( + self, + group_id: ContextGroupId, + target_application: Application<'a>, + migration_method: Option, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::SetTargetApplication { + target_application, + migration_method, + }, + )), + } + } + + pub fn commit_group_invitation( + self, + group_id: ContextGroupId, + commitment_hash: String, + expiration_block_height: BlockHeight, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::CommitGroupInvitation { + commitment_hash, + expiration_block_height, + }, + )), + } + } + + pub fn join_context_via_group( + self, + group_id: ContextGroupId, + context_id: ContextId, + new_member: ContextIdentity, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::JoinContextViaGroup { + context_id: Repr::new(context_id), + new_member: Repr::new(new_member), + }, + )), + } + } + + pub fn reveal_group_invitation( + self, + group_id: ContextGroupId, + payload: SignedGroupRevealPayload, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::RevealGroupInvitation { payload }, + )), + } + } + + pub fn set_member_capabilities( + self, + group_id: ContextGroupId, + member: SignerId, + capabilities: u32, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::SetMemberCapabilities { + member: Repr::new(member), + capabilities, + }, + )), + } + } + + pub fn set_context_visibility( + self, + group_id: ContextGroupId, + context_id: ContextId, + mode: VisibilityMode, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::SetContextVisibility { + context_id: Repr::new(context_id), + mode, + }, + )), + } + } + + pub fn manage_context_allowlist( + self, + group_id: ContextGroupId, + context_id: ContextId, + add: &'a [SignerId], + remove: &'a [SignerId], + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::ManageContextAllowlist { + context_id: Repr::new(context_id), + add: add.iter().map(|s| Repr::new(*s)).collect(), + remove: remove.iter().map(|s| Repr::new(*s)).collect(), + }, + )), + } + } + + pub fn set_default_capabilities( + self, + group_id: ContextGroupId, + default_capabilities: u32, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::SetDefaultCapabilities { + default_capabilities, + }, + )), + } + } + + pub fn set_default_visibility( + self, + group_id: ContextGroupId, + default_visibility: VisibilityMode, + ) -> ContextConfigMutateRequest<'a, T> { + ContextConfigMutateRequest { + client: self.client, + kind: RequestKind::Group(GroupRequest::new( + Repr::new(group_id), + GroupRequestKind::SetDefaultVisibility { default_visibility }, + )), + } + } } impl<'a, T: Transport> ContextConfigMutateRequest<'a, T> { diff --git a/crates/context/config/src/client/env/config/query.rs b/crates/context/config/src/client/env/config/query.rs index f6bd8caaf2..c7f3408de7 100644 --- a/crates/context/config/src/client/env/config/query.rs +++ b/crates/context/config/src/client/env/config/query.rs @@ -2,14 +2,19 @@ use std::collections::BTreeMap; use super::requests::{ - ApplicationRequest, ApplicationRevisionRequest, FetchNonceRequest, HasMemberRequest, + ApplicationRequest, ApplicationRevisionRequest, ContextAllowlistRequest, ContextGroupRequest, + ContextVisibilityQueryResponse, ContextVisibilityRequest, FetchGroupNonceRequest, + FetchNonceRequest, GroupContextsRequest, GroupInfoQueryResponse, GroupInfoRequest, + GroupMemberQueryEntry, GroupMembersRequest, HasMemberRequest, IsGroupAdminRequest, MembersRequest, MembersRevisionRequest, PrivilegesRequest, ProxyContractRequest, }; use crate::client::env::utils; use crate::client::transport::Transport; use crate::client::{CallClient, ClientError, Operation}; use crate::repr::Repr; -use crate::types::{Application, Capability, ContextId, ContextIdentity, Revision, SignerId}; +use crate::types::{ + Application, Capability, ContextGroupId, ContextId, ContextIdentity, Revision, SignerId, +}; #[derive(Debug)] pub struct ContextConfigQuery<'a, T> { @@ -108,6 +113,114 @@ impl<'a, T: Transport> ContextConfigQuery<'a, T> { utils::send(&self.client, Operation::Read(params)).await } + + pub async fn group_info( + &self, + group_id: ContextGroupId, + ) -> Result, ClientError> { + let params = GroupInfoRequest { + group_id: Repr::new(group_id), + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn is_group_admin( + &self, + group_id: ContextGroupId, + identity: SignerId, + ) -> Result> { + let params = IsGroupAdminRequest { + group_id: Repr::new(group_id), + identity: Repr::new(identity), + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn group_contexts( + &self, + group_id: ContextGroupId, + offset: usize, + length: usize, + ) -> Result, ClientError> { + let params = GroupContextsRequest { + group_id: Repr::new(group_id), + offset, + length, + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn group_members( + &self, + group_id: ContextGroupId, + offset: usize, + length: usize, + ) -> Result, ClientError> { + let params = GroupMembersRequest { + group_id: Repr::new(group_id), + offset, + length, + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn context_group( + &self, + context_id: ContextId, + ) -> Result, ClientError> { + let params = ContextGroupRequest { + context_id: Repr::new(context_id), + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn fetch_group_nonce( + &self, + group_id: ContextGroupId, + admin_id: SignerId, + ) -> Result, ClientError> { + let params = FetchGroupNonceRequest { + group_id: Repr::new(group_id), + admin_id: Repr::new(admin_id), + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn context_visibility( + &self, + group_id: ContextGroupId, + context_id: ContextId, + ) -> Result, ClientError> { + let params = ContextVisibilityRequest { + group_id: Repr::new(group_id), + context_id: Repr::new(context_id), + }; + + utils::send(&self.client, Operation::Read(params)).await + } + + pub async fn context_allowlist( + &self, + group_id: ContextGroupId, + context_id: ContextId, + offset: usize, + length: usize, + ) -> Result, ClientError> { + let params = ContextAllowlistRequest { + group_id: Repr::new(group_id), + context_id: Repr::new(context_id), + offset, + length, + }; + + utils::send(&self.client, Operation::Read(params)).await + } } pub mod near; diff --git a/crates/context/config/src/client/env/config/query/near.rs b/crates/context/config/src/client/env/config/query/near.rs index b55771647b..39a41d6b81 100644 --- a/crates/context/config/src/client/env/config/query/near.rs +++ b/crates/context/config/src/client/env/config/query/near.rs @@ -1,14 +1,18 @@ -use core::mem; use std::collections::BTreeMap; use crate::client::env::config::requests::{ - ApplicationRequest, ApplicationRevisionRequest, FetchNonceRequest, HasMemberRequest, + ApplicationRequest, ApplicationRevisionRequest, ContextAllowlistRequest, ContextGroupRequest, + ContextVisibilityQueryResponse, ContextVisibilityRequest, FetchGroupNonceRequest, + FetchNonceRequest, GroupContextsRequest, GroupInfoQueryResponse, GroupInfoRequest, + GroupMemberQueryEntry, GroupMembersRequest, HasMemberRequest, IsGroupAdminRequest, MembersRequest, MembersRevisionRequest, PrivilegesRequest, ProxyContractRequest, }; use crate::client::env::Method; use crate::client::protocol::near::Near; use crate::repr::Repr; -use crate::types::{Application, Capability, ContextIdentity, Revision, SignerId}; +use crate::types::{ + Application, Capability, ContextGroupId, ContextId, ContextIdentity, Revision, SignerId, +}; impl Method for ApplicationRequest { const METHOD: &'static str = "application"; @@ -60,13 +64,7 @@ impl Method for MembersRequest { fn decode(response: Vec) -> eyre::Result { let members: Vec> = serde_json::from_slice(&response)?; - // safety: `Repr` is a transparent wrapper around `T` - #[expect( - clippy::transmute_undefined_repr, - reason = "Repr is a transparent wrapper around T" - )] - let members = - unsafe { mem::transmute::>, Vec>(members) }; + let members: Vec = members.into_iter().map(|r| r.into_inner()).collect(); Ok(members) } @@ -113,17 +111,10 @@ impl<'a> Method for PrivilegesRequest<'a> { let privileges: BTreeMap, Vec> = serde_json::from_slice(&response)?; - // safety: `Repr` is a transparent wrapper around `T` - let privileges = unsafe { - #[expect( - clippy::transmute_undefined_repr, - reason = "Repr is a transparent wrapper around T" - )] - mem::transmute::< - BTreeMap, Vec>, - BTreeMap>, - >(privileges) - }; + let privileges: BTreeMap> = privileges + .into_iter() + .map(|(k, v)| (k.into_inner(), v)) + .collect(); Ok(privileges) } @@ -156,3 +147,127 @@ impl Method for FetchNonceRequest { serde_json::from_slice(&response).map_err(Into::into) } } + +impl Method for GroupInfoRequest { + const METHOD: &'static str = "group"; + + type Returns = Option; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + serde_json::from_slice(&response).map_err(Into::into) + } +} + +impl Method for IsGroupAdminRequest { + const METHOD: &'static str = "is_group_admin"; + + type Returns = bool; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + serde_json::from_slice(&response).map_err(Into::into) + } +} + +impl Method for GroupContextsRequest { + const METHOD: &'static str = "group_contexts"; + + type Returns = Vec; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + let contexts: Vec> = serde_json::from_slice(&response)?; + + let contexts: Vec = contexts.into_iter().map(|r| r.into_inner()).collect(); + + Ok(contexts) + } +} + +impl Method for GroupMembersRequest { + const METHOD: &'static str = "group_members"; + + type Returns = Vec; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + serde_json::from_slice(&response).map_err(Into::into) + } +} + +impl Method for ContextGroupRequest { + const METHOD: &'static str = "context_group"; + + type Returns = Option; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + let group_id: Option> = serde_json::from_slice(&response)?; + + let group_id: Option = group_id.map(|r| r.into_inner()); + + Ok(group_id) + } +} + +impl Method for FetchGroupNonceRequest { + const METHOD: &'static str = "fetch_group_nonce"; + + type Returns = Option; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + serde_json::from_slice(&response).map_err(Into::into) + } +} + +impl Method for ContextVisibilityRequest { + const METHOD: &'static str = "context_visibility"; + + type Returns = Option; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + serde_json::from_slice(&response).map_err(Into::into) + } +} + +impl Method for ContextAllowlistRequest { + const METHOD: &'static str = "context_allowlist"; + + type Returns = Vec; + + fn encode(self) -> eyre::Result> { + serde_json::to_vec(&self).map_err(Into::into) + } + + fn decode(response: Vec) -> eyre::Result { + let members: Vec> = serde_json::from_slice(&response)?; + + let members: Vec = members.into_iter().map(|r| r.into_inner()).collect(); + + Ok(members) + } +} diff --git a/crates/context/config/src/client/env/config/requests.rs b/crates/context/config/src/client/env/config/requests.rs index a631152d32..f41dafdf84 100644 --- a/crates/context/config/src/client/env/config/requests.rs +++ b/crates/context/config/src/client/env/config/requests.rs @@ -6,11 +6,12 @@ use core::ptr; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::repr::Repr; use crate::types::{ - Application, BlockHeight, Capability, ContextId, ContextIdentity, SignedRevealPayload, + AppKey, Application, BlockHeight, Capability, ContextGroupId, ContextId, ContextIdentity, + SignedRevealPayload, SignerId, }; // ============================================================================ @@ -93,6 +94,98 @@ impl FetchNonceRequest { } } +/// Request to get group info. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct GroupInfoRequest { + pub group_id: Repr, +} + +/// Request to check if an identity is a group admin. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct IsGroupAdminRequest { + pub group_id: Repr, + pub identity: Repr, +} + +/// Request to get contexts in a group with pagination. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct GroupContextsRequest { + pub group_id: Repr, + pub offset: usize, + pub length: usize, +} + +/// Request to get members of a group with pagination. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct GroupMembersRequest { + pub group_id: Repr, + pub offset: usize, + pub length: usize, +} + +/// Response entry for a group member query. +#[derive(Debug, Deserialize)] +pub struct GroupMemberQueryEntry { + pub identity: Repr, + pub role: String, + pub capabilities: u32, +} + +/// Request to get which group a context belongs to. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct ContextGroupRequest { + pub context_id: Repr, +} + +/// Request to fetch group nonce for an admin. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct FetchGroupNonceRequest { + pub group_id: Repr, + pub admin_id: Repr, +} + +/// Response type for group info queries. +/// +/// Uses `serde_json::Value` for `target_application` because the contract +/// serializes `Application<'static>` (which contains `#[serde(borrow)]` +/// `Cow` fields) and deserializing it back into `Application<'static>` +/// requires `'de: 'static` — a constraint that `Method::Returns` (which +/// must be `'static`) cannot satisfy with `serde_json::from_slice`. +#[derive(Debug, Deserialize)] +pub struct GroupInfoQueryResponse { + pub app_key: Repr, + pub target_application: serde_json::Value, + pub member_count: u64, + pub context_count: u64, + pub migration_method: Option, + pub default_member_capabilities: u32, + pub default_context_visibility: crate::VisibilityMode, +} + +/// Response type for context visibility queries. +#[derive(Debug, Deserialize)] +pub struct ContextVisibilityQueryResponse { + pub mode: crate::VisibilityMode, + pub creator: Repr, + pub allowlist_count: u64, +} + +/// Request to get context visibility info. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct ContextVisibilityRequest { + pub group_id: Repr, + pub context_id: Repr, +} + +/// Request to get context allowlist (paginated). +#[derive(Copy, Clone, Debug, Serialize)] +pub struct ContextAllowlistRequest { + pub group_id: Repr, + pub context_id: Repr, + pub offset: usize, + pub length: usize, +} + // ============================================================================ // Mutate Request Types // ============================================================================ diff --git a/crates/context/config/src/lib.rs b/crates/context/config/src/lib.rs index 80b6407807..575d0d39ab 100644 --- a/crates/context/config/src/lib.rs +++ b/crates/context/config/src/lib.rs @@ -12,8 +12,8 @@ pub mod types; use repr::Repr; use types::{ - Application, BlockHeight, Capability, ContextId, ContextIdentity, ProposalId, - SignedRevealPayload, SignerId, + AppKey, Application, BlockHeight, Capability, ContextGroupId, ContextId, ContextIdentity, + ProposalId, SignedGroupRevealPayload, SignedRevealPayload, SignerId, }; pub type Timestamp = u64; @@ -47,6 +47,8 @@ impl<'a> Request<'a> { pub enum RequestKind<'a> { #[serde(borrow)] Context(ContextRequest<'a>), + #[serde(borrow)] + Group(GroupRequest<'a>), } #[derive(Debug, Serialize, Deserialize)] @@ -103,6 +105,114 @@ pub enum ContextRequestKind<'a> { UpdateProxyContract, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct GroupRequest<'a> { + pub group_id: Repr, + + #[serde(borrow, flatten)] + pub kind: GroupRequestKind<'a>, +} + +impl<'a> GroupRequest<'a> { + #[must_use] + pub const fn new(group_id: Repr, kind: GroupRequestKind<'a>) -> Self { + GroupRequest { group_id, kind } + } +} + +/// Visibility mode for a context within a group. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum VisibilityMode { + Open, + Restricted, +} + +/// Bitfield constants for group member capabilities. +#[derive(Copy, Clone, Debug)] +pub struct MemberCapabilities; + +impl MemberCapabilities { + pub const CAN_CREATE_CONTEXT: u32 = 1 << 0; + pub const CAN_INVITE_MEMBERS: u32 = 1 << 1; + pub const CAN_JOIN_OPEN_CONTEXTS: u32 = 1 << 2; +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "scope", content = "params")] +#[serde(deny_unknown_fields)] +#[expect(clippy::exhaustive_enums, reason = "Considered to be exhaustive")] +pub enum GroupRequestKind<'a> { + Create { + app_key: Repr, + #[serde(borrow)] + target_application: Application<'a>, + }, + Delete, + AddMembers { + members: Cow<'a, [Repr]>, + }, + RemoveMembers { + members: Cow<'a, [Repr]>, + }, + RegisterContext { + context_id: Repr, + visibility_mode: Option, + }, + UnregisterContext { + context_id: Repr, + }, + SetTargetApplication { + #[serde(borrow)] + target_application: Application<'a>, + migration_method: Option, + }, + /// Pre-approve a specific context to register via its proxy contract. + /// Must be called by a group admin before the proxy path is exercised. + ApproveContextRegistration { + context_id: Repr, + }, + CommitGroupInvitation { + commitment_hash: String, + expiration_block_height: BlockHeight, + }, + RevealGroupInvitation { + payload: SignedGroupRevealPayload, + }, + /// Join a context within a group using group membership as authorization. + /// Caller must be a group member; the context must belong to the group. + JoinContextViaGroup { + context_id: Repr, + new_member: Repr, + }, + /// Set capability bits for a specific member (admin-only). + SetMemberCapabilities { + member: Repr, + capabilities: u32, + }, + /// Set visibility mode for a context (creator or admin). + SetContextVisibility { + context_id: Repr, + mode: VisibilityMode, + }, + /// Add/remove members from a context's allowlist (creator or admin). + ManageContextAllowlist { + context_id: Repr, + add: Vec>, + remove: Vec>, + }, + /// Set the default capability bits for new members (admin-only). + SetDefaultCapabilities { + default_capabilities: u32, + }, + /// Set the default visibility mode for new contexts (admin-only). + SetDefaultVisibility { + default_visibility: VisibilityMode, + }, +} + #[derive(Copy, Clone, Debug, Serialize, Deserialize)] #[serde(tag = "scope", content = "params")] #[serde(deny_unknown_fields)] @@ -156,6 +266,12 @@ pub enum ProposalAction { DeleteProposal { proposal_id: Repr, }, + RegisterInGroup { + group_id: Repr, + }, + UnregisterFromGroup { + group_id: Repr, + }, } // The proposal the user makes specifying the receiving account and actions they want to execute (1 tx) diff --git a/crates/context/config/src/repr.rs b/crates/context/config/src/repr.rs index 8adc01e551..fb5330a31d 100644 --- a/crates/context/config/src/repr.rs +++ b/crates/context/config/src/repr.rs @@ -42,6 +42,30 @@ impl Repr { pub fn into_inner(self) -> T { self.inner } + + /// Reinterprets `&[T]` as `&[Repr]` without copying. + /// + /// Safe because `Repr` is `#[repr(transparent)]` over `T`, meaning they + /// share identical size, alignment, and ABI. The inline `const` assertions + /// verify this at compile time per monomorphization so that removing + /// `#[repr(transparent)]` would become a compile error rather than UB. + pub fn slice_from_inner(slice: &[T]) -> &[Self] { + const { + assert!( + core::mem::size_of::() == core::mem::size_of::>(), + "Repr size mismatch — is #[repr(transparent)] still present?" + ) + }; + const { + assert!( + core::mem::align_of::() == core::mem::align_of::>(), + "Repr alignment mismatch — is #[repr(transparent)] still present?" + ) + }; + // SAFETY: `Repr` is `#[repr(transparent)]` over `T`; identical + // layout is enforced by the const assertions above. + unsafe { &*(core::ptr::from_ref::<[T]>(slice) as *const [Self]) } + } } impl Display for Repr { diff --git a/crates/context/config/src/types.rs b/crates/context/config/src/types.rs index 454a3db02b..02191a1ce1 100644 --- a/crates/context/config/src/types.rs +++ b/crates/context/config/src/types.rs @@ -103,7 +103,18 @@ impl From<[u8; 32]> for Identity { } #[derive( - Eq, Ord, Copy, Debug, Clone, PartialEq, PartialOrd, BorshSerialize, BorshDeserialize, Hash, + Eq, + Ord, + Copy, + Debug, + Deserialize, + Clone, + PartialEq, + PartialOrd, + BorshSerialize, + BorshDeserialize, + Hash, + Serialize, )] pub struct SignerId(Identity); @@ -125,6 +136,18 @@ impl ReprBytes for SignerId { } } +impl SignerId { + pub fn to_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } +} + +impl From<[u8; 32]> for SignerId { + fn from(value: [u8; 32]) -> Self { + Self(Identity(value)) + } +} + #[derive( Eq, Ord, @@ -226,6 +249,98 @@ impl From<[u8; 32]> for ContextIdentity { } } +#[derive( + Eq, + Ord, + Copy, + Debug, + Deserialize, + Clone, + PartialEq, + PartialOrd, + BorshSerialize, + BorshDeserialize, + Serialize, + Hash, +)] +pub struct ContextGroupId(Identity); + +impl ContextGroupId { + pub fn to_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } +} + +impl ReprBytes for ContextGroupId { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + ReprBytes::from_bytes(f).map(Self) + } +} + +impl From<[u8; 32]> for ContextGroupId { + fn from(value: [u8; 32]) -> Self { + Self(Identity(value)) + } +} + +#[derive( + Eq, + Ord, + Copy, + Debug, + Deserialize, + Clone, + PartialEq, + PartialOrd, + BorshSerialize, + BorshDeserialize, + Serialize, + Hash, +)] +pub struct AppKey(Identity); + +impl AppKey { + pub fn to_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } +} + +impl ReprBytes for AppKey { + type EncodeBytes<'a> = [u8; 32]; + type DecodeBytes = [u8; 32]; + + type Error = LengthMismatch; + + fn as_bytes(&self) -> Self::EncodeBytes<'_> { + self.0.as_bytes() + } + + fn from_bytes(f: F) -> repr::Result + where + F: FnOnce(&mut Self::DecodeBytes) -> Bs58Result, + { + ReprBytes::from_bytes(f).map(Self) + } +} + +impl From<[u8; 32]> for AppKey { + fn from(value: [u8; 32]) -> Self { + Self(Identity(value)) + } +} + #[derive(Eq, Ord, Copy, Debug, Clone, PartialEq, PartialOrd, BorshSerialize, BorshDeserialize)] pub struct BlobId(Identity); @@ -574,6 +689,53 @@ pub struct SignedRevealPayload { pub invitee_signature: String, } +/// An open invitation payload for joining a context group. +/// Created and signed by a group admin. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Deserialize, Serialize)] +pub struct GroupInvitationFromAdmin { + /// The identity of the inviter (group admin public key). + pub inviter_identity: SignerId, + /// The group being invited to. + pub group_id: ContextGroupId, + /// The block height at which the invitation expires. + pub expiration_height: BlockHeight, + /// Secret salt for MEV protection. + pub secret_salt: [u8; 32], + /// The protocol ID (e.g. "near"). + pub protocol: String, + /// The protocol network (e.g. "testnet"). + pub network: String, + /// The contract ID on the target protocol. + pub contract_id: String, +} + +/// A container for a group invitation and the admin's signature over it. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Deserialize, Serialize)] +pub struct SignedGroupOpenInvitation { + /// The open invitation to the group. + pub invitation: GroupInvitationFromAdmin, + /// Admin's signature for the invitation payload (hex-encoded). + pub inviter_signature: String, +} + +/// The full payload the joiner reveals in the second transaction. +#[derive(BorshSerialize, BorshDeserialize, Debug, Deserialize, Clone, Serialize)] +pub struct GroupRevealPayloadData { + /// The signed open invitation from the admin. + pub signed_open_invitation: SignedGroupOpenInvitation, + /// The identity of the new member joining the group. + pub new_member_identity: SignerId, +} + +/// The final object submitted to the `reveal_group_invitation` method. +#[derive(BorshSerialize, BorshDeserialize, Debug, Deserialize, Clone, Serialize)] +pub struct SignedGroupRevealPayload { + /// The data needed to join the group. + pub data: GroupRevealPayloadData, + /// The joiner's signature over the `data`. + pub invitee_signature: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/context/primitives/Cargo.toml b/crates/context/primitives/Cargo.toml index 644870ed87..50648774c8 100644 --- a/crates/context/primitives/Cargo.toml +++ b/crates/context/primitives/Cargo.toml @@ -12,6 +12,7 @@ publish = true actix.workspace = true async-stream.workspace = true borsh = { workspace = true, features = ["derive"] } +ed25519-dalek.workspace = true eyre.workspace = true futures-util.workspace = true hex.workspace = true diff --git a/crates/context/primitives/src/client.rs b/crates/context/primitives/src/client.rs index b4683fee98..be86f91cff 100644 --- a/crates/context/primitives/src/client.rs +++ b/crates/context/primitives/src/client.rs @@ -4,7 +4,8 @@ use async_stream::try_stream; use borsh::BorshDeserialize; use calimero_context_config::client::{AnyTransport, Client as ExternalClient}; use calimero_context_config::types::{ - BlockHeight, InvitationFromMember, RevealPayloadData, SignedOpenInvitation, SignedRevealPayload, + BlockHeight, ContextGroupId, InvitationFromMember, RevealPayloadData, SignedOpenInvitation, + SignedRevealPayload, }; use calimero_node_primitives::client::NodeClient; use calimero_primitives::alias::Alias; @@ -23,6 +24,24 @@ use rand::Rng; use sha2::{Digest, Sha256}; use tokio::sync::oneshot; +use crate::group::{ + AddGroupMembersRequest, BroadcastGroupAliasesRequest, BroadcastGroupLocalStateRequest, + CreateGroupInvitationRequest, CreateGroupInvitationResponse, CreateGroupRequest, + CreateGroupResponse, DeleteGroupRequest, DeleteGroupResponse, DetachContextFromGroupRequest, + GetContextAllowlistRequest, GetContextVisibilityRequest, GetContextVisibilityResponse, + GetGroupForContextRequest, GetGroupInfoRequest, GetGroupUpgradeStatusRequest, + GetMemberCapabilitiesRequest, GetMemberCapabilitiesResponse, GroupContextEntry, + GroupInfoResponse, GroupMemberEntry, GroupSummary, GroupUpgradeInfo, JoinGroupContextRequest, + JoinGroupContextResponse, JoinGroupRequest, JoinGroupResponse, ListAllGroupsRequest, + ListGroupContextsRequest, ListGroupMembersRequest, ManageContextAllowlistRequest, + RemoveGroupMembersRequest, RetryGroupUpgradeRequest, SetContextVisibilityRequest, + SetDefaultCapabilitiesRequest, SetDefaultVisibilityRequest, SetGroupAliasRequest, + SetMemberAliasRequest, SetMemberCapabilitiesRequest, StoreContextAliasRequest, + StoreContextAllowlistRequest, StoreContextVisibilityRequest, StoreDefaultCapabilitiesRequest, + StoreDefaultVisibilityRequest, StoreGroupAliasRequest, StoreGroupContextRequest, + StoreMemberAliasRequest, StoreMemberCapabilityRequest, SyncGroupRequest, SyncGroupResponse, + UpdateGroupSettingsRequest, UpdateMemberRoleRequest, UpgradeGroupRequest, UpgradeGroupResponse, +}; use crate::messages::{ ContextMessage, CreateContextRequest, CreateContextResponse, DeleteContextRequest, DeleteContextResponse, ExecuteError, ExecuteRequest, ExecuteResponse, JoinContextRequest, @@ -97,6 +116,8 @@ impl ContextClient { identity_secret: Option, init_params: Vec, seed: Option<[u8; DIGEST_SIZE]>, + group_id: Option, + alias: Option, ) -> eyre::Result { let (sender, receiver) = oneshot::channel(); @@ -108,6 +129,8 @@ impl ContextClient { application_id: *application_id, identity_secret, init_params, + group_id, + alias, }, outcome: sender, }) @@ -999,6 +1022,7 @@ impl ContextClient { pub async fn delete_context( &self, context_id: &ContextId, + requester: Option, ) -> eyre::Result { let (sender, receiver) = oneshot::channel(); @@ -1006,6 +1030,7 @@ impl ContextClient { .send(ContextMessage::DeleteContext { request: DeleteContextRequest { context_id: *context_id, + requester, }, outcome: sender, }) @@ -1014,4 +1039,656 @@ impl ContextClient { receiver.await.expect("Mailbox not to be dropped") } + + // ----- Group operations ----- + + pub async fn create_group( + &self, + request: CreateGroupRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::CreateGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn delete_group( + &self, + request: DeleteGroupRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::DeleteGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn add_group_members(&self, request: AddGroupMembersRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::AddGroupMembers { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn remove_group_members( + &self, + request: RemoveGroupMembersRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::RemoveGroupMembers { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_group_info( + &self, + request: GetGroupInfoRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetGroupInfo { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn list_group_members( + &self, + request: ListGroupMembersRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::ListGroupMembers { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn list_group_contexts( + &self, + request: ListGroupContextsRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::ListGroupContexts { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_context_alias(&self, request: StoreContextAliasRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreContextAlias { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn broadcast_group_aliases( + &self, + request: BroadcastGroupAliasesRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::BroadcastGroupAliases { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn broadcast_group_local_state( + &self, + request: BroadcastGroupLocalStateRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::BroadcastGroupLocalState { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_member_capability( + &self, + request: StoreMemberCapabilityRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreMemberCapability { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_default_capabilities( + &self, + request: StoreDefaultCapabilitiesRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreDefaultCapabilities { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_context_visibility( + &self, + request: StoreContextVisibilityRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreContextVisibility { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_default_visibility( + &self, + request: StoreDefaultVisibilityRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreDefaultVisibility { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_context_allowlist( + &self, + request: StoreContextAllowlistRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreContextAllowlist { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_member_alias(&self, request: SetMemberAliasRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetMemberAlias { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_member_alias(&self, request: StoreMemberAliasRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreMemberAlias { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_group_alias(&self, request: SetGroupAliasRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetGroupAlias { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_group_alias(&self, request: StoreGroupAliasRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreGroupAlias { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn store_group_context(&self, request: StoreGroupContextRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::StoreGroupContext { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn upgrade_group( + &self, + request: UpgradeGroupRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::UpgradeGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_group_upgrade_status( + &self, + request: GetGroupUpgradeStatusRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetGroupUpgradeStatus { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn retry_group_upgrade( + &self, + request: RetryGroupUpgradeRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::RetryGroupUpgrade { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn create_group_invitation( + &self, + request: CreateGroupInvitationRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::CreateGroupInvitation { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn join_group(&self, request: JoinGroupRequest) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::JoinGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn list_all_groups( + &self, + request: ListAllGroupsRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::ListAllGroups { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn update_group_settings( + &self, + request: UpdateGroupSettingsRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::UpdateGroupSettings { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn update_member_role(&self, request: UpdateMemberRoleRequest) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::UpdateMemberRole { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn detach_context_from_group( + &self, + request: DetachContextFromGroupRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::DetachContextFromGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_group_for_context( + &self, + request: GetGroupForContextRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetGroupForContext { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn sync_group(&self, request: SyncGroupRequest) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SyncGroup { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn join_group_context( + &self, + request: JoinGroupContextRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::JoinGroupContext { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_member_capabilities( + &self, + request: SetMemberCapabilitiesRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetMemberCapabilities { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_member_capabilities( + &self, + request: GetMemberCapabilitiesRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetMemberCapabilities { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_context_visibility( + &self, + request: SetContextVisibilityRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetContextVisibility { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_context_visibility( + &self, + request: GetContextVisibilityRequest, + ) -> eyre::Result { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetContextVisibility { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn manage_context_allowlist( + &self, + request: ManageContextAllowlistRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::ManageContextAllowlist { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn get_context_allowlist( + &self, + request: GetContextAllowlistRequest, + ) -> eyre::Result> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::GetContextAllowlist { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_default_capabilities( + &self, + request: SetDefaultCapabilitiesRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetDefaultCapabilities { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } + + pub async fn set_default_visibility( + &self, + request: SetDefaultVisibilityRequest, + ) -> eyre::Result<()> { + let (sender, receiver) = oneshot::channel(); + + self.context_manager + .send(ContextMessage::SetDefaultVisibility { + request, + outcome: sender, + }) + .await + .expect("Mailbox not to be dropped"); + + receiver.await.expect("Mailbox not to be dropped") + } } diff --git a/crates/context/primitives/src/client/external.rs b/crates/context/primitives/src/client/external.rs index 59c75eebda..27e3340177 100644 --- a/crates/context/primitives/src/client/external.rs +++ b/crates/context/primitives/src/client/external.rs @@ -7,6 +7,7 @@ use calimero_store::key; use super::ContextClient; mod config; +pub mod group; mod proxy; impl ContextClient { diff --git a/crates/context/primitives/src/client/external/group.rs b/crates/context/primitives/src/client/external/group.rs new file mode 100644 index 0000000000..5ab06ce282 --- /dev/null +++ b/crates/context/primitives/src/client/external/group.rs @@ -0,0 +1,577 @@ +use core::future::Future; + +use calimero_context_config::client::env::config::ContextConfig; +use calimero_context_config::client::{AnyTransport, Client}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_config::types::{self as types}; +use calimero_primitives::context::ContextId; +use ed25519_dalek::SigningKey; +use eyre::bail; + +use super::ContextClient; + +const MAX_RETRIES: u8 = 3; + +/// Immutable configuration for a group contract client. +/// +/// Separated from the mutable `nonce` field so that Rust 2021's field-level +/// closure capture allows closures to borrow `inner` while `nonce` is +/// mutably borrowed by the retry loop. +struct GroupClientInner { + signing_key: [u8; 32], + group_id: types::ContextGroupId, + protocol: String, + network_id: String, + contract_id: String, + sdk_client: Client, +} + +/// A contract client for group operations. +/// +/// Unlike `ExternalConfigClient` (context-scoped, borrows `ContextClient`), +/// this struct owns a clone of the SDK `Client` so it can be +/// moved into `'static` async blocks inside actix handlers. +pub struct ExternalGroupClient { + nonce: Option, + inner: GroupClientInner, +} + +impl ContextClient { + pub fn group_client( + &self, + group_id: types::ContextGroupId, + signing_key: [u8; 32], + protocol: String, + network_id: String, + contract_id: String, + ) -> ExternalGroupClient { + ExternalGroupClient { + nonce: None, + inner: GroupClientInner { + signing_key, + group_id, + protocol, + network_id, + contract_id, + sdk_client: self.external_client.clone(), + }, + } + } + + /// Read-only query for group info from the on-chain contract. + pub async fn query_group_info( + &self, + group_id: types::ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, + ) -> eyre::Result< + Option, + > { + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + query.group_info(group_id).await.map_err(Into::into) + } + + /// Read-only query for group contexts from the on-chain contract (paginated). + pub async fn query_group_contexts( + &self, + group_id: types::ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, + offset: usize, + length: usize, + ) -> eyre::Result> { + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + let config_ids = query.group_contexts(group_id, offset, length).await?; + Ok(config_ids + .into_iter() + .map(|id| ContextId::from(id.to_bytes())) + .collect()) + } + + /// Read-only query for group members from the on-chain contract (paginated). + pub async fn query_group_members( + &self, + group_id: types::ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, + offset: usize, + length: usize, + ) -> eyre::Result< + Vec, + > { + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + query + .group_members(group_id, offset, length) + .await + .map_err(Into::into) + } + + /// Read-only query for context visibility from the on-chain contract. + pub async fn query_context_visibility( + &self, + group_id: types::ContextGroupId, + context_id: ContextId, + protocol: &str, + network_id: &str, + contract_id: &str, + ) -> eyre::Result< + Option< + calimero_context_config::client::env::config::requests::ContextVisibilityQueryResponse, + >, + > { + let context_id: types::ContextId = context_id.rt()?; + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + query + .context_visibility(group_id, context_id) + .await + .map_err(Into::into) + } + + /// Read-only query for context allowlist from the on-chain contract (paginated). + pub async fn query_context_allowlist( + &self, + group_id: types::ContextGroupId, + context_id: ContextId, + protocol: &str, + network_id: &str, + contract_id: &str, + offset: usize, + length: usize, + ) -> eyre::Result> { + let context_id: types::ContextId = context_id.rt()?; + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + query + .context_allowlist(group_id, context_id, offset, length) + .await + .map_err(Into::into) + } + + /// Read-only query to check if an identity is a group admin on-chain. + pub async fn query_is_group_admin( + &self, + group_id: types::ContextGroupId, + identity: types::SignerId, + protocol: &str, + network_id: &str, + contract_id: &str, + ) -> eyre::Result { + let query = self.external_client.query::( + protocol.into(), + network_id.into(), + contract_id.into(), + ); + query + .is_group_admin(group_id, identity) + .await + .map_err(Into::into) + } +} + +impl GroupClientInner { + fn signer_id(&self) -> eyre::Result { + let sk = SigningKey::from_bytes(&self.signing_key); + sk.verifying_key().rt().map_err(Into::into) + } + + async fn fetch_nonce(&self) -> eyre::Result { + let query = self.sdk_client.query::( + self.protocol.as_str().into(), + self.network_id.as_str().into(), + self.contract_id.as_str().into(), + ); + + let signer_id = self.signer_id()?; + + let nonce = query + .fetch_group_nonce(self.group_id, signer_id) + .await? + .unwrap_or(0); + + Ok(nonce) + } +} + +/// Retry loop with nonce management. +/// +/// Free function (not a method) so the call site can split borrows: +/// `&mut self.nonce` is mutably borrowed while `&self.inner` is shared. +async fn with_nonce( + nonce: &mut Option, + inner: &GroupClientInner, + f: impl Fn(u64) -> F, +) -> eyre::Result +where + E: Into, + F: Future>, +{ + let retries = MAX_RETRIES + u8::from(nonce.is_none()); + + for _ in 0..=retries { + let mut error = None; + + if let Some(n) = *nonce { + match f(n).await { + Ok(value) => return Ok(value), + Err(err) => error = Some(err), + } + } + + let old = *nonce; + + *nonce = Some(inner.fetch_nonce().await?); + + if let Some(err) = error { + if old == *nonce { + return Err(err.into()); + } + } + } + + bail!("max retries exceeded"); +} + +impl ExternalGroupClient { + pub async fn create_group( + &mut self, + app_key: types::AppKey, + target_application: types::Application<'_>, + ) -> eyre::Result<()> { + let c = &self.inner; + + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .create_group(c.group_id, app_key, target_application) + .send(c.signing_key, 0) + .await?; + + Ok(()) + } + + pub async fn delete_group(&mut self) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .delete_group(c.group_id) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn add_group_members(&mut self, members: &[types::SignerId]) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .add_group_members(c.group_id, members) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn remove_group_members(&mut self, members: &[types::SignerId]) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .remove_group_members(c.group_id, members) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn register_context_in_group( + &mut self, + context_id: ContextId, + visibility_mode: Option, + ) -> eyre::Result<()> { + let context_id: types::ContextId = context_id.rt()?; + + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .register_context_in_group(c.group_id, context_id, visibility_mode) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn unregister_context_from_group( + &mut self, + context_id: ContextId, + ) -> eyre::Result<()> { + let context_id: types::ContextId = context_id.rt()?; + + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .unregister_context_from_group(c.group_id, context_id) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn set_group_target( + &mut self, + target_application: types::Application<'_>, + migration_method: Option, + ) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .set_group_target( + c.group_id, + target_application.clone(), + migration_method.clone(), + ) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + /// Join a context via group membership. No nonce required (caller may be a + /// regular member with no admin nonce). + pub async fn join_context_via_group( + &self, + context_id: ContextId, + new_member: types::ContextIdentity, + ) -> eyre::Result<()> { + let context_id: types::ContextId = context_id.rt()?; + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .join_context_via_group(c.group_id, context_id, new_member) + .send(c.signing_key, 0) + .await?; + + Ok(()) + } + + /// Commit a group invitation hash on-chain. No nonce required. + pub async fn commit_group_invitation( + &self, + commitment_hash: String, + expiration_block_height: u64, + ) -> eyre::Result<()> { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .commit_group_invitation(c.group_id, commitment_hash, expiration_block_height) + .send(c.signing_key, 0) + .await?; + + Ok(()) + } + + pub async fn set_member_capabilities( + &mut self, + member: types::SignerId, + capabilities: u32, + ) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .set_member_capabilities(c.group_id, member, capabilities) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn set_context_visibility( + &mut self, + context_id: ContextId, + mode: calimero_context_config::VisibilityMode, + ) -> eyre::Result<()> { + let context_id: types::ContextId = context_id.rt()?; + + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .set_context_visibility(c.group_id, context_id, mode) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn manage_context_allowlist( + &mut self, + context_id: ContextId, + add: Vec, + remove: Vec, + ) -> eyre::Result<()> { + let context_id: types::ContextId = context_id.rt()?; + + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .manage_context_allowlist(c.group_id, context_id, &add, &remove) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn set_default_capabilities( + &mut self, + default_capabilities: u32, + ) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .set_default_capabilities(c.group_id, default_capabilities) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + pub async fn set_default_visibility( + &mut self, + default_visibility: calimero_context_config::VisibilityMode, + ) -> eyre::Result<()> { + with_nonce(&mut self.nonce, &self.inner, async |nonce| { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .set_default_visibility(c.group_id, default_visibility) + .send(c.signing_key, nonce) + .await + }) + .await?; + + Ok(()) + } + + /// Reveal a group invitation on-chain. No nonce required. + pub async fn reveal_group_invitation( + &self, + payload: types::SignedGroupRevealPayload, + ) -> eyre::Result<()> { + let c = &self.inner; + c.sdk_client + .mutate::( + c.protocol.as_str().into(), + c.network_id.as_str().into(), + c.contract_id.as_str().into(), + ) + .reveal_group_invitation(c.group_id, payload) + .send(c.signing_key, 0) + .await?; + + Ok(()) + } +} diff --git a/crates/context/primitives/src/group.rs b/crates/context/primitives/src/group.rs new file mode 100644 index 0000000000..70f32b21d6 --- /dev/null +++ b/crates/context/primitives/src/group.rs @@ -0,0 +1,571 @@ +use actix::Message; +use calimero_context_config::types::{AppKey, ContextGroupId, SignedGroupOpenInvitation}; +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::{ContextId, GroupMemberRole, UpgradePolicy}; +use calimero_primitives::identity::PublicKey; + +use crate::messages::MigrationParams; + +pub use calimero_store::key::GroupUpgradeStatus; + +/// Snapshot of an in-progress or completed group upgrade, returned by the API. +/// +/// Contains the full context of the upgrade operation including source/target +/// versions, optional migration method, and current progress status. +#[derive(Clone, Debug)] +pub struct GroupUpgradeInfo { + /// Semver version of the application before the upgrade, read from the + /// current application's `ApplicationMeta.version`. + pub from_version: String, + /// Semver version of the target application, read from the target + /// application's `ApplicationMeta.version`. + pub to_version: String, + /// Optional Borsh-serialized migration method name. + pub migration: Option>, + /// Unix timestamp (seconds) when the upgrade was initiated. + pub initiated_at: u64, + /// Identity of the admin who initiated the upgrade. + pub initiated_by: PublicKey, + /// Current progress of the upgrade. + pub status: GroupUpgradeStatus, +} + +#[derive(Debug)] +pub struct CreateGroupRequest { + pub group_id: Option, + pub app_key: Option, + pub application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub alias: Option, +} + +impl Message for CreateGroupRequest { + type Result = eyre::Result; +} + +#[derive(Copy, Clone, Debug)] +pub struct CreateGroupResponse { + pub group_id: ContextGroupId, +} + +#[derive(Clone, Debug)] +pub struct DeleteGroupRequest { + pub group_id: ContextGroupId, + pub requester: Option, +} + +impl Message for DeleteGroupRequest { + type Result = eyre::Result; +} + +#[derive(Copy, Clone, Debug)] +pub struct DeleteGroupResponse { + pub deleted: bool, +} + +#[derive(Debug)] +pub struct AddGroupMembersRequest { + pub group_id: ContextGroupId, + pub members: Vec<(PublicKey, GroupMemberRole)>, + pub requester: Option, +} + +impl Message for AddGroupMembersRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct RemoveGroupMembersRequest { + pub group_id: ContextGroupId, + pub members: Vec, + pub requester: Option, +} + +impl Message for RemoveGroupMembersRequest { + type Result = eyre::Result<()>; +} + +#[derive(Copy, Clone, Debug)] +pub struct GetGroupInfoRequest { + pub group_id: ContextGroupId, +} + +impl Message for GetGroupInfoRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct GroupInfoResponse { + pub group_id: ContextGroupId, + pub app_key: AppKey, + pub target_application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub member_count: u64, + pub context_count: u64, + pub active_upgrade: Option, + pub default_capabilities: u32, + pub default_visibility: String, + pub alias: Option, +} + +#[derive(Debug)] +pub struct ListGroupMembersRequest { + pub group_id: ContextGroupId, + pub offset: usize, + pub limit: usize, +} + +impl Message for ListGroupMembersRequest { + type Result = eyre::Result>; +} + +#[derive(Clone, Debug)] +pub struct GroupMemberEntry { + pub identity: PublicKey, + pub role: GroupMemberRole, + pub alias: Option, +} + +#[derive(Clone, Debug)] +pub struct GroupContextEntry { + pub context_id: ContextId, + pub alias: Option, +} + +#[derive(Debug)] +pub struct ListGroupContextsRequest { + pub group_id: ContextGroupId, + pub offset: usize, + pub limit: usize, +} + +impl Message for ListGroupContextsRequest { + type Result = eyre::Result>; +} + +#[derive(Debug)] +pub struct StoreContextAliasRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub alias: String, +} + +impl Message for StoreContextAliasRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug, Clone)] +pub struct UpgradeGroupRequest { + pub group_id: ContextGroupId, + pub target_application_id: ApplicationId, + pub requester: Option, + pub migration: Option, +} + +impl Message for UpgradeGroupRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct UpgradeGroupResponse { + pub group_id: ContextGroupId, + pub status: GroupUpgradeStatus, +} + +#[derive(Debug)] +pub struct GetGroupUpgradeStatusRequest { + pub group_id: ContextGroupId, +} + +impl Message for GetGroupUpgradeStatusRequest { + type Result = eyre::Result>; +} + +#[derive(Debug)] +pub struct RetryGroupUpgradeRequest { + pub group_id: ContextGroupId, + pub requester: Option, +} + +impl Message for RetryGroupUpgradeRequest { + type Result = eyre::Result; +} + +#[derive(Debug)] +pub struct CreateGroupInvitationRequest { + pub group_id: ContextGroupId, + pub requester: Option, + /// On-chain block height after which the invitation commitment expires. + /// Defaults to 999_999_999 when not provided (backward-compatible). + pub expiration_block_height: Option, +} + +impl Message for CreateGroupInvitationRequest { + type Result = eyre::Result; +} + +#[derive(Debug)] +pub struct CreateGroupInvitationResponse { + pub invitation: SignedGroupOpenInvitation, + pub group_alias: Option, +} + +#[derive(Debug)] +pub struct JoinGroupRequest { + pub invitation: SignedGroupOpenInvitation, + pub group_alias: Option, +} + +impl Message for JoinGroupRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct JoinGroupResponse { + pub group_id: ContextGroupId, + pub member_identity: PublicKey, +} + +#[derive(Debug)] +pub struct ListAllGroupsRequest { + pub offset: usize, + pub limit: usize, +} + +impl Message for ListAllGroupsRequest { + type Result = eyre::Result>; +} + +#[derive(Clone, Debug)] +pub struct GroupSummary { + pub group_id: ContextGroupId, + pub app_key: AppKey, + pub target_application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub created_at: u64, + pub alias: Option, +} + +#[derive(Debug)] +pub struct UpdateGroupSettingsRequest { + pub group_id: ContextGroupId, + pub requester: Option, + pub upgrade_policy: UpgradePolicy, +} + +impl Message for UpdateGroupSettingsRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct UpdateMemberRoleRequest { + pub group_id: ContextGroupId, + pub identity: PublicKey, + pub new_role: GroupMemberRole, + pub requester: Option, +} + +impl Message for UpdateMemberRoleRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct DetachContextFromGroupRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub requester: Option, +} + +impl Message for DetachContextFromGroupRequest { + type Result = eyre::Result<()>; +} + +#[derive(Copy, Clone, Debug)] +pub struct GetGroupForContextRequest { + pub context_id: ContextId, +} + +impl Message for GetGroupForContextRequest { + type Result = eyre::Result>; +} + +#[derive(Debug)] +pub struct SyncGroupRequest { + pub group_id: ContextGroupId, + pub requester: Option, + /// Optional contract coordinates. If not provided, uses the node's + /// configured "near" protocol params. + pub protocol: Option, + pub network_id: Option, + pub contract_id: Option, +} + +impl Message for SyncGroupRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct SyncGroupResponse { + pub group_id: ContextGroupId, + pub app_key: [u8; 32], + pub target_application_id: ApplicationId, + pub member_count: u64, + pub context_count: u64, +} + +#[derive(Debug)] +pub struct JoinGroupContextRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, +} + +impl Message for JoinGroupContextRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct JoinGroupContextResponse { + pub context_id: ContextId, + pub member_public_key: PublicKey, +} + +// ---- Group Permission Types ---- + +#[derive(Debug)] +pub struct SetMemberCapabilitiesRequest { + pub group_id: ContextGroupId, + pub member: PublicKey, + pub capabilities: u32, + pub requester: Option, +} + +impl Message for SetMemberCapabilitiesRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct GetMemberCapabilitiesRequest { + pub group_id: ContextGroupId, + pub member: PublicKey, +} + +impl Message for GetMemberCapabilitiesRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct GetMemberCapabilitiesResponse { + pub capabilities: u32, +} + +#[derive(Debug)] +pub struct SetContextVisibilityRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub mode: calimero_context_config::VisibilityMode, + pub requester: Option, +} + +impl Message for SetContextVisibilityRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct GetContextVisibilityRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, +} + +impl Message for GetContextVisibilityRequest { + type Result = eyre::Result; +} + +#[derive(Clone, Debug)] +pub struct GetContextVisibilityResponse { + pub mode: calimero_context_config::VisibilityMode, + pub creator: PublicKey, +} + +#[derive(Debug)] +pub struct ManageContextAllowlistRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub add: Vec, + pub remove: Vec, + pub requester: Option, +} + +impl Message for ManageContextAllowlistRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct GetContextAllowlistRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, +} + +impl Message for GetContextAllowlistRequest { + type Result = eyre::Result>; +} + +#[derive(Debug)] +pub struct SetDefaultCapabilitiesRequest { + pub group_id: ContextGroupId, + pub default_capabilities: u32, + pub requester: Option, +} + +impl Message for SetDefaultCapabilitiesRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct SetDefaultVisibilityRequest { + pub group_id: ContextGroupId, + pub default_visibility: calimero_context_config::VisibilityMode, + pub requester: Option, +} + +impl Message for SetDefaultVisibilityRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct BroadcastGroupAliasesRequest { + pub group_id: ContextGroupId, +} + +impl Message for BroadcastGroupAliasesRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct BroadcastGroupLocalStateRequest { + pub group_id: ContextGroupId, +} + +impl Message for BroadcastGroupLocalStateRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct SetMemberAliasRequest { + pub group_id: ContextGroupId, + pub member: PublicKey, + pub alias: String, + pub requester: Option, +} + +impl Message for SetMemberAliasRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreMemberAliasRequest { + pub group_id: ContextGroupId, + pub member: PublicKey, + pub alias: String, +} + +impl Message for StoreMemberAliasRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct SetGroupAliasRequest { + pub group_id: ContextGroupId, + pub alias: String, + pub requester: Option, +} + +impl Message for SetGroupAliasRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreGroupAliasRequest { + pub group_id: ContextGroupId, + pub alias: String, +} + +impl Message for StoreGroupAliasRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreGroupContextRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, +} + +impl Message for StoreGroupContextRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreMemberCapabilityRequest { + pub group_id: ContextGroupId, + pub member: PublicKey, + pub capabilities: u32, +} + +impl Message for StoreMemberCapabilityRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreDefaultCapabilitiesRequest { + pub group_id: ContextGroupId, + pub capabilities: u32, +} + +impl Message for StoreDefaultCapabilitiesRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreContextVisibilityRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub mode: u8, + pub creator: PublicKey, +} + +impl Message for StoreContextVisibilityRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreDefaultVisibilityRequest { + pub group_id: ContextGroupId, + pub mode: u8, +} + +impl Message for StoreDefaultVisibilityRequest { + type Result = eyre::Result<()>; +} + +#[derive(Debug)] +pub struct StoreContextAllowlistRequest { + pub group_id: ContextGroupId, + pub context_id: ContextId, + pub members: Vec, +} + +impl Message for StoreContextAllowlistRequest { + type Result = eyre::Result<()>; +} + +impl From for GroupUpgradeInfo { + fn from(v: calimero_store::key::GroupUpgradeValue) -> Self { + Self { + from_version: v.from_version, + to_version: v.to_version, + migration: v.migration, + initiated_at: v.initiated_at, + initiated_by: v.initiated_by, + status: v.status.into(), + } + } +} diff --git a/crates/context/primitives/src/lib.rs b/crates/context/primitives/src/lib.rs index 66dabbfe08..de81328e5e 100644 --- a/crates/context/primitives/src/lib.rs +++ b/crates/context/primitives/src/lib.rs @@ -2,6 +2,7 @@ use calimero_primitives::context::ContextId; use tokio::sync::OwnedMutexGuard; pub mod client; +pub mod group; pub mod messages; #[derive(Debug)] diff --git a/crates/context/primitives/src/messages.rs b/crates/context/primitives/src/messages.rs index 58194e723d..55b9a94fa5 100644 --- a/crates/context/primitives/src/messages.rs +++ b/crates/context/primitives/src/messages.rs @@ -1,5 +1,5 @@ use actix::Message; -use calimero_context_config::types::SignedRevealPayload; +use calimero_context_config::types::{ContextGroupId, SignedRevealPayload}; use calimero_primitives::alias::Alias; use calimero_primitives::application::ApplicationId; use calimero_primitives::context::{ContextId, ContextInvitationPayload}; @@ -9,6 +9,21 @@ use serde::{Deserialize, Serialize}; use thiserror::Error as ThisError; use tokio::sync::oneshot; +use crate::group::{ + AddGroupMembersRequest, BroadcastGroupAliasesRequest, BroadcastGroupLocalStateRequest, + CreateGroupInvitationRequest, CreateGroupRequest, DeleteGroupRequest, + DetachContextFromGroupRequest, GetContextAllowlistRequest, GetContextVisibilityRequest, + GetGroupForContextRequest, GetGroupInfoRequest, GetGroupUpgradeStatusRequest, + GetMemberCapabilitiesRequest, JoinGroupContextRequest, JoinGroupRequest, ListAllGroupsRequest, + ListGroupContextsRequest, ListGroupMembersRequest, ManageContextAllowlistRequest, + RemoveGroupMembersRequest, RetryGroupUpgradeRequest, SetContextVisibilityRequest, + SetDefaultCapabilitiesRequest, SetDefaultVisibilityRequest, SetGroupAliasRequest, + SetMemberAliasRequest, SetMemberCapabilitiesRequest, StoreContextAliasRequest, + StoreContextAllowlistRequest, StoreContextVisibilityRequest, StoreDefaultCapabilitiesRequest, + StoreDefaultVisibilityRequest, StoreGroupAliasRequest, StoreGroupContextRequest, + StoreMemberAliasRequest, StoreMemberCapabilityRequest, SyncGroupRequest, + UpdateGroupSettingsRequest, UpdateMemberRoleRequest, UpgradeGroupRequest, +}; use crate::{ContextAtomic, ContextAtomicKey}; #[derive(Debug)] @@ -18,6 +33,8 @@ pub struct CreateContextRequest { pub application_id: ApplicationId, pub identity_secret: Option, pub init_params: Vec, + pub group_id: Option, + pub alias: Option, } impl Message for CreateContextRequest { @@ -33,6 +50,7 @@ pub struct CreateContextResponse { #[derive(Copy, Clone, Debug)] pub struct DeleteContextRequest { pub context_id: ContextId, + pub requester: Option, } impl Message for DeleteContextRequest { @@ -186,4 +204,164 @@ pub enum ContextMessage { request: SyncRequest, outcome: oneshot::Sender<::Result>, }, + CreateGroup { + request: CreateGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + DeleteGroup { + request: DeleteGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + AddGroupMembers { + request: AddGroupMembersRequest, + outcome: oneshot::Sender<::Result>, + }, + RemoveGroupMembers { + request: RemoveGroupMembersRequest, + outcome: oneshot::Sender<::Result>, + }, + GetGroupInfo { + request: GetGroupInfoRequest, + outcome: oneshot::Sender<::Result>, + }, + ListGroupMembers { + request: ListGroupMembersRequest, + outcome: oneshot::Sender<::Result>, + }, + ListGroupContexts { + request: ListGroupContextsRequest, + outcome: oneshot::Sender<::Result>, + }, + UpgradeGroup { + request: UpgradeGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + GetGroupUpgradeStatus { + request: GetGroupUpgradeStatusRequest, + outcome: oneshot::Sender<::Result>, + }, + RetryGroupUpgrade { + request: RetryGroupUpgradeRequest, + outcome: oneshot::Sender<::Result>, + }, + CreateGroupInvitation { + request: CreateGroupInvitationRequest, + outcome: oneshot::Sender<::Result>, + }, + JoinGroup { + request: JoinGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + ListAllGroups { + request: ListAllGroupsRequest, + outcome: oneshot::Sender<::Result>, + }, + UpdateGroupSettings { + request: UpdateGroupSettingsRequest, + outcome: oneshot::Sender<::Result>, + }, + UpdateMemberRole { + request: UpdateMemberRoleRequest, + outcome: oneshot::Sender<::Result>, + }, + DetachContextFromGroup { + request: DetachContextFromGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + GetGroupForContext { + request: GetGroupForContextRequest, + outcome: oneshot::Sender<::Result>, + }, + SyncGroup { + request: SyncGroupRequest, + outcome: oneshot::Sender<::Result>, + }, + JoinGroupContext { + request: JoinGroupContextRequest, + outcome: oneshot::Sender<::Result>, + }, + SetMemberCapabilities { + request: SetMemberCapabilitiesRequest, + outcome: oneshot::Sender<::Result>, + }, + GetMemberCapabilities { + request: GetMemberCapabilitiesRequest, + outcome: oneshot::Sender<::Result>, + }, + SetContextVisibility { + request: SetContextVisibilityRequest, + outcome: oneshot::Sender<::Result>, + }, + GetContextVisibility { + request: GetContextVisibilityRequest, + outcome: oneshot::Sender<::Result>, + }, + ManageContextAllowlist { + request: ManageContextAllowlistRequest, + outcome: oneshot::Sender<::Result>, + }, + GetContextAllowlist { + request: GetContextAllowlistRequest, + outcome: oneshot::Sender<::Result>, + }, + SetDefaultCapabilities { + request: SetDefaultCapabilitiesRequest, + outcome: oneshot::Sender<::Result>, + }, + SetDefaultVisibility { + request: SetDefaultVisibilityRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreContextAlias { + request: StoreContextAliasRequest, + outcome: oneshot::Sender<::Result>, + }, + BroadcastGroupAliases { + request: BroadcastGroupAliasesRequest, + outcome: oneshot::Sender<::Result>, + }, + BroadcastGroupLocalState { + request: BroadcastGroupLocalStateRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreMemberCapability { + request: StoreMemberCapabilityRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreDefaultCapabilities { + request: StoreDefaultCapabilitiesRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreContextVisibility { + request: StoreContextVisibilityRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreDefaultVisibility { + request: StoreDefaultVisibilityRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreContextAllowlist { + request: StoreContextAllowlistRequest, + outcome: oneshot::Sender<::Result>, + }, + SetMemberAlias { + request: SetMemberAliasRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreMemberAlias { + request: StoreMemberAliasRequest, + outcome: oneshot::Sender<::Result>, + }, + SetGroupAlias { + request: SetGroupAliasRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreGroupAlias { + request: StoreGroupAliasRequest, + outcome: oneshot::Sender<::Result>, + }, + StoreGroupContext { + request: StoreGroupContextRequest, + outcome: oneshot::Sender<::Result>, + }, } diff --git a/crates/context/src/group_store.rs b/crates/context/src/group_store.rs new file mode 100644 index 0000000000..97980ee9c3 --- /dev/null +++ b/crates/context/src/group_store.rs @@ -0,0 +1,2040 @@ +use std::collections::HashSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use calimero_context_config::repr::ReprBytes; +use calimero_context_config::types::ContextGroupId; +use calimero_context_primitives::client::ContextClient; +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::{ContextId, GroupMemberRole}; +use calimero_primitives::identity::PublicKey; +use calimero_store::key::{ + AsKeyParts, ContextGroupRef, ContextIdentity, GroupAlias, GroupContextAlias, + GroupContextAllowlist, GroupContextIndex, GroupContextLastMigration, + GroupContextLastMigrationValue, GroupContextVisibility, GroupContextVisibilityValue, + GroupDefaultCaps, GroupDefaultCapsValue, GroupDefaultVis, GroupDefaultVisValue, GroupMember, + GroupMemberAlias, GroupMemberCapability, GroupMemberCapabilityValue, GroupMeta, GroupMetaValue, + GroupSigningKey, GroupSigningKeyValue, GroupUpgradeKey, GroupUpgradeStatus, GroupUpgradeValue, + GROUP_CONTEXT_ALLOWLIST_PREFIX, GROUP_CONTEXT_INDEX_PREFIX, GROUP_CONTEXT_VISIBILITY_PREFIX, + GROUP_MEMBER_ALIAS_PREFIX, GROUP_MEMBER_CAPABILITY_PREFIX, GROUP_MEMBER_PREFIX, + GROUP_META_PREFIX, GROUP_SIGNING_KEY_PREFIX, GROUP_UPGRADE_PREFIX, +}; +use calimero_store::Store; +use eyre::{bail, Result as EyreResult}; +use tracing::{debug, warn}; + +// --------------------------------------------------------------------------- +// Group meta helpers +// --------------------------------------------------------------------------- + +pub fn load_group_meta( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupMeta::new(group_id.to_bytes()); + let value = handle.get(&key)?; + Ok(value) +} + +pub fn save_group_meta( + store: &Store, + group_id: &ContextGroupId, + meta: &GroupMetaValue, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupMeta::new(group_id.to_bytes()); + handle.put(&key, meta)?; + Ok(()) +} + +pub fn delete_group_meta(store: &Store, group_id: &ContextGroupId) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupMeta::new(group_id.to_bytes()); + handle.delete(&key)?; + Ok(()) +} + +pub fn enumerate_all_groups( + store: &Store, + offset: usize, + limit: usize, +) -> EyreResult> { + let handle = store.handle(); + let start_key = GroupMeta::new([0u8; 32]); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + let mut skipped = 0usize; + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + + if key.as_key().as_bytes()[0] != GROUP_META_PREFIX { + break; + } + + if skipped < offset { + skipped += 1; + continue; + } + + if results.len() >= limit { + break; + } + + let Some(meta) = handle.get(&key)? else { + continue; + }; + results.push((key.group_id(), meta)); + } + + Ok(results) +} + +// --------------------------------------------------------------------------- +// Group member helpers +// --------------------------------------------------------------------------- + +pub fn add_group_member( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, + role: GroupMemberRole, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupMember::new(group_id.to_bytes(), *identity); + handle.put(&key, &role)?; + Ok(()) +} + +pub fn remove_group_member( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupMember::new(group_id.to_bytes(), *identity); + handle.delete(&key)?; + Ok(()) +} + +pub fn get_group_member_role( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupMember::new(group_id.to_bytes(), *identity); + let value = handle.get(&key)?; + Ok(value) +} + +pub fn check_group_membership( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, +) -> EyreResult { + let handle = store.handle(); + let key = GroupMember::new(group_id.to_bytes(), *identity); + let exists = handle.has(&key)?; + Ok(exists) +} + +pub fn is_group_admin( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, +) -> EyreResult { + match get_group_member_role(store, group_id, identity)? { + Some(GroupMemberRole::Admin) => Ok(true), + _ => Ok(false), + } +} + +pub fn require_group_admin( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, +) -> EyreResult<()> { + if !is_group_admin(store, group_id, identity)? { + bail!("requester is not an admin of group '{group_id:?}'"); + } + Ok(()) +} + +/// Returns `true` if `identity` is a group admin **or** holds the given capability bit. +/// Admins always pass regardless of capability bits. +pub fn is_group_admin_or_has_capability( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, + capability_bit: u32, +) -> EyreResult { + if is_group_admin(store, group_id, identity)? { + return Ok(true); + } + let caps = get_member_capability(store, group_id, identity)?.unwrap_or(0); + Ok(caps & capability_bit != 0) +} + +/// Enforces that `identity` is a group admin or holds the given capability bit. +pub fn require_group_admin_or_capability( + store: &Store, + group_id: &ContextGroupId, + identity: &PublicKey, + capability_bit: u32, + operation: &str, +) -> EyreResult<()> { + if !is_group_admin_or_has_capability(store, group_id, identity, capability_bit)? { + bail!( + "requester lacks permission to {operation} in group '{group_id:?}' \ + (not an admin and capability bit 0x{capability_bit:x} is not set)" + ); + } + Ok(()) +} + +// TODO: replace with iter.entries() for a single-pass scan once the +// Iter::read() / Iter::next() borrow-conflict (read takes &'a self) is +// resolved in the store API — currently each value requires a separate +// handle.get() lookup after collecting the key. +pub fn count_group_admins(store: &Store, group_id: &ContextGroupId) -> EyreResult { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupMember::new(group_id_bytes, [0u8; 32].into()); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut count = 0usize; + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + if key.as_key().as_bytes()[0] != GROUP_MEMBER_PREFIX { + break; + } + if key.group_id() != group_id_bytes { + break; + } + let role = handle + .get(&key)? + .ok_or_else(|| eyre::eyre!("member key exists but value is missing"))?; + if role == GroupMemberRole::Admin { + count += 1; + } + } + + Ok(count) +} + +pub fn list_group_members( + store: &Store, + group_id: &ContextGroupId, + offset: usize, + limit: usize, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupMember::new(group_id_bytes, [0u8; 32].into()); + let mut iter = handle.iter::()?; + let first_key = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + let mut skipped = 0usize; + + // TODO: replace with iter.entries() for a single-pass scan once the + // Iter::read() / Iter::next() borrow-conflict (read takes &'a self) is + // resolved in the store API — currently each value requires a separate + // handle.get() lookup after collecting the key. + for key_result in first_key.into_iter().chain(iter.keys()) { + let key = key_result?; + + if key.as_key().as_bytes()[0] != GROUP_MEMBER_PREFIX { + break; + } + + if key.group_id() != group_id_bytes { + break; + } + + if skipped < offset { + skipped += 1; + continue; + } + + if results.len() >= limit { + break; + } + + let role = handle + .get(&key)? + .ok_or_else(|| eyre::eyre!("member key exists but value is missing"))?; + results.push((key.identity(), role)); + } + + Ok(results) +} + +pub fn count_group_members(store: &Store, group_id: &ContextGroupId) -> EyreResult { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupMember::new(group_id_bytes, [0u8; 32].into()); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut count = 0usize; + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + if key.as_key().as_bytes()[0] != GROUP_MEMBER_PREFIX { + break; + } + if key.group_id() != group_id_bytes { + break; + } + count += 1; + } + + Ok(count) +} + +/// Scans the ContextIdentity column for the given context and returns the first +/// `PublicKey` for which the node holds a local private key. Used to find a +/// valid signer when performing group upgrades on behalf of a context that the +/// group admin may not be a member of. +pub fn find_local_signing_identity( + store: &Store, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + let start_key = ContextIdentity::new(*context_id, [0u8; 32].into()); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + if key.context_id() != *context_id { + break; + } + let Some(value) = handle.get(&key)? else { + continue; + }; + if value.private_key.is_some() { + return Ok(Some(key.public_key())); + } + } + + Ok(None) +} + +// --------------------------------------------------------------------------- +// Group signing key helpers +// --------------------------------------------------------------------------- + +pub fn store_group_signing_key( + store: &Store, + group_id: &ContextGroupId, + public_key: &PublicKey, + private_key: &[u8; 32], +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupSigningKey::new(group_id.to_bytes(), *public_key); + handle.put( + &key, + &GroupSigningKeyValue { + private_key: *private_key, + }, + )?; + Ok(()) +} + +pub fn get_group_signing_key( + store: &Store, + group_id: &ContextGroupId, + public_key: &PublicKey, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupSigningKey::new(group_id.to_bytes(), *public_key); + let value = handle.get(&key)?; + Ok(value.map(|v| v.private_key)) +} + +pub fn delete_group_signing_key( + store: &Store, + group_id: &ContextGroupId, + public_key: &PublicKey, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupSigningKey::new(group_id.to_bytes(), *public_key); + handle.delete(&key)?; + Ok(()) +} + +/// Verify that the node holds a signing key for the given requester in this group. +pub fn require_group_signing_key( + store: &Store, + group_id: &ContextGroupId, + requester: &PublicKey, +) -> EyreResult<()> { + if get_group_signing_key(store, group_id, requester)?.is_none() { + bail!( + "node does not hold a signing key for requester in group '{group_id:?}'; \ + register one via POST /admin-api/groups//signing-key" + ); + } + Ok(()) +} + +/// Delete all signing keys for a group (used during group deletion). +pub fn delete_all_group_signing_keys(store: &Store, group_id: &ContextGroupId) -> EyreResult<()> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupSigningKey::new(group_id_bytes, [0u8; 32].into()); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + + let mut keys_to_delete = Vec::new(); + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + if key.as_key().as_bytes()[0] != GROUP_SIGNING_KEY_PREFIX { + break; + } + if key.group_id() != group_id_bytes { + break; + } + keys_to_delete.push(key); + } + drop(iter); + + let mut handle = store.handle(); + for key in keys_to_delete { + handle.delete(&key)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Context-group index helpers +// --------------------------------------------------------------------------- + +pub fn register_context_in_group( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult<()> { + let mut handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + + // If already registered in a different group, remove the stale index entry + // to prevent orphaned counts and enumerations for the old group. + let ref_key = ContextGroupRef::new(*context_id); + if let Some(existing_group_bytes) = handle.get(&ref_key)? { + if existing_group_bytes != group_id_bytes { + let old_idx = GroupContextIndex::new(existing_group_bytes, *context_id); + handle.delete(&old_idx)?; + } + } + + let idx_key = GroupContextIndex::new(group_id_bytes, *context_id); + handle.put(&idx_key, &())?; + handle.put(&ref_key, &group_id_bytes)?; + + Ok(()) +} + +pub fn unregister_context_from_group( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult<()> { + let mut handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + + let idx_key = GroupContextIndex::new(group_id_bytes, *context_id); + handle.delete(&idx_key)?; + + let ref_key = ContextGroupRef::new(*context_id); + handle.delete(&ref_key)?; + + Ok(()) +} + +pub fn get_group_for_context( + store: &Store, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + let key = ContextGroupRef::new(*context_id); + let value = handle.get(&key)?; + Ok(value.map(ContextGroupId::from)) +} + +pub fn enumerate_group_contexts( + store: &Store, + group_id: &ContextGroupId, + offset: usize, + limit: usize, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupContextIndex::new(group_id_bytes, ContextId::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + let mut skipped = 0usize; + + for entry in first.into_iter().chain(iter.keys()) { + let key = entry?; + + if key.as_key().as_bytes()[0] != GROUP_CONTEXT_INDEX_PREFIX { + break; + } + + if key.group_id() != group_id_bytes { + break; + } + + if skipped < offset { + skipped += 1; + continue; + } + + if results.len() >= limit { + break; + } + + results.push(key.context_id()); + } + + Ok(results) +} + +/// Stores a human-readable alias for a context within a group. +pub fn set_context_alias( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + alias: &str, +) -> EyreResult<()> { + let mut handle = store.handle(); + handle.put( + &GroupContextAlias::new(group_id.to_bytes(), *context_id), + &alias.to_owned(), + )?; + Ok(()) +} + +/// Returns the alias for a context within a group, if one was set. +pub fn get_context_alias( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + handle + .get(&GroupContextAlias::new(group_id.to_bytes(), *context_id)) + .map_err(Into::into) +} + +/// Returns context IDs together with their optional aliases. +pub fn enumerate_group_contexts_with_aliases( + store: &Store, + group_id: &ContextGroupId, + offset: usize, + limit: usize, +) -> EyreResult)>> { + let ids = enumerate_group_contexts(store, group_id, offset, limit)?; + ids.into_iter() + .map(|ctx_id| { + let alias = get_context_alias(store, group_id, &ctx_id)?; + Ok((ctx_id, alias)) + }) + .collect() +} + +/// Stores a human-readable alias for a group member within a group. +pub fn set_member_alias( + store: &Store, + group_id: &ContextGroupId, + member: &PublicKey, + alias: &str, +) -> EyreResult<()> { + let mut handle = store.handle(); + handle.put( + &GroupMemberAlias::new(group_id.to_bytes(), *member), + &alias.to_owned(), + )?; + Ok(()) +} + +/// Returns the alias for a group member within a group, if one was set. +pub fn get_member_alias( + store: &Store, + group_id: &ContextGroupId, + member: &PublicKey, +) -> EyreResult> { + let handle = store.handle(); + handle + .get(&GroupMemberAlias::new(group_id.to_bytes(), *member)) + .map_err(Into::into) +} + +/// Stores a human-readable alias for the group itself. +pub fn set_group_alias(store: &Store, group_id: &ContextGroupId, alias: &str) -> EyreResult<()> { + let mut handle = store.handle(); + handle.put(&GroupAlias::new(group_id.to_bytes()), &alias.to_owned())?; + Ok(()) +} + +/// Returns the alias for a group, if one was set. +pub fn get_group_alias(store: &Store, group_id: &ContextGroupId) -> EyreResult> { + let handle = store.handle(); + handle + .get(&GroupAlias::new(group_id.to_bytes())) + .map_err(Into::into) +} + +/// Returns all member aliases stored for a group as `(PublicKey, alias_string)` pairs. +pub fn enumerate_member_aliases( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupMemberAlias::new(group_id_bytes, PublicKey::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + + for entry in first.into_iter().chain(iter.keys()) { + let key = entry?; + if key.as_key().as_bytes()[0] != GROUP_MEMBER_ALIAS_PREFIX { + break; + } + if key.group_id() != group_id_bytes { + break; + } + let Some(alias) = handle.get(&key)? else { + continue; + }; + results.push((key.member(), alias)); + } + + Ok(results) +} + +pub fn count_group_contexts(store: &Store, group_id: &ContextGroupId) -> EyreResult { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupContextIndex::new(group_id_bytes, ContextId::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut count = 0usize; + + for entry in first.into_iter().chain(iter.keys()) { + let key = entry?; + if key.as_key().as_bytes()[0] != GROUP_CONTEXT_INDEX_PREFIX { + break; + } + if key.group_id() != group_id_bytes { + break; + } + count += 1; + } + + Ok(count) +} + +// --------------------------------------------------------------------------- +// Group upgrade helpers +// --------------------------------------------------------------------------- + +pub fn save_group_upgrade( + store: &Store, + group_id: &ContextGroupId, + upgrade: &GroupUpgradeValue, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupUpgradeKey::new(group_id.to_bytes()); + handle.put(&key, upgrade)?; + Ok(()) +} + +pub fn load_group_upgrade( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupUpgradeKey::new(group_id.to_bytes()); + let value = handle.get(&key)?; + Ok(value) +} + +pub fn delete_group_upgrade(store: &Store, group_id: &ContextGroupId) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupUpgradeKey::new(group_id.to_bytes()); + handle.delete(&key)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Cross-node sync helpers +// --------------------------------------------------------------------------- + +/// Queries the on-chain contract for group state and updates local storage. +/// Returns the synced `GroupMetaValue` and the raw `GroupInfoQueryResponse` +/// (so callers can extract target application blob info for P2P sharing). +/// +/// Syncs metadata (app_key, target_application), group contexts, and group +/// members from the on-chain contract. Prunes locally-stored entries that no +/// longer exist on-chain. +// TODO(test): add integration test with mock ContextClient — tracked in PR #2043 review +pub async fn sync_group_state_from_contract( + datastore: &Store, + context_client: &ContextClient, + group_id: &ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, +) -> EyreResult<( + GroupMetaValue, + calimero_context_config::client::env::config::requests::GroupInfoQueryResponse, +)> { + let info = context_client + .query_group_info(*group_id, protocol, network_id, contract_id) + .await? + .ok_or_else(|| eyre::eyre!("group '{group_id:?}' not found on-chain"))?; + + let app_key: [u8; 32] = info.app_key.to_bytes(); + let target_application_id = extract_application_id(&info.target_application)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| { + warn!("system clock is before Unix epoch, using 0 as timestamp"); + std::time::Duration::ZERO + }) + .as_secs(); + + let existing = load_group_meta(datastore, group_id)?; + let meta = GroupMetaValue { + app_key, + target_application_id, + upgrade_policy: existing + .as_ref() + .map(|m| m.upgrade_policy.clone()) + .unwrap_or_default(), + created_at: existing.as_ref().map(|m| m.created_at).unwrap_or(now), + admin_identity: existing + .as_ref() + .map(|m| m.admin_identity) + .unwrap_or_else(|| PublicKey::from([0u8; 32])), + migration: info + .migration_method + .as_ref() + .map(|m| m.as_bytes().to_vec()), + }; + + save_group_meta(datastore, group_id, &meta)?; + + // Sync group contexts from on-chain state. + sync_group_contexts_from_contract( + datastore, + context_client, + group_id, + protocol, + network_id, + contract_id, + ) + .await?; + + // Sync group members from on-chain state. + sync_group_members_from_contract( + datastore, + context_client, + group_id, + protocol, + network_id, + contract_id, + ) + .await?; + + // Sync group default capabilities and visibility. + set_default_capabilities(datastore, group_id, info.default_member_capabilities)?; + let vis_mode = match info.default_context_visibility { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + set_default_visibility(datastore, group_id, vis_mode)?; + + Ok((meta, info)) +} + +/// Extracts the blob ID and source URL from the target application JSON +/// returned by the on-chain contract query. +pub fn extract_application_blob_info( + app_json: &serde_json::Value, +) -> Option<(calimero_primitives::blobs::BlobId, String, u64)> { + use calimero_context_config::repr::{Repr, ReprBytes}; + use calimero_context_config::types::BlobId as ConfigBlobId; + + let blob_val = app_json.get("blob")?; + let repr: Repr = serde_json::from_value(blob_val.clone()).ok()?; + let blob_id = calimero_primitives::blobs::BlobId::from(repr.as_bytes()); + + let source = app_json.get("source")?.as_str()?.to_owned(); + let size = app_json.get("size")?.as_u64().unwrap_or(0); + + Some((blob_id, source, size)) +} + +/// Paginates through `query_group_contexts()` and reconciles the local +/// context-group index with the on-chain state (upsert + prune). +async fn sync_group_contexts_from_contract( + datastore: &Store, + context_client: &ContextClient, + group_id: &ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, +) -> EyreResult<()> { + const PAGE_SIZE: usize = 100; + + let mut on_chain_contexts = HashSet::new(); + let mut offset = 0; + + loop { + let page = context_client + .query_group_contexts( + *group_id, + protocol, + network_id, + contract_id, + offset, + PAGE_SIZE, + ) + .await?; + + let page_len = page.len(); + for context_id in page { + on_chain_contexts.insert(context_id); + register_context_in_group(datastore, group_id, &context_id)?; + } + + if page_len < PAGE_SIZE { + break; + } + offset += page_len; + } + + // Prune locally-registered contexts that no longer exist on-chain. + let local_contexts = enumerate_group_contexts(datastore, group_id, 0, usize::MAX)?; + for local_ctx in local_contexts { + if !on_chain_contexts.contains(&local_ctx) { + unregister_context_from_group(datastore, group_id, &local_ctx)?; + debug!(?group_id, ?local_ctx, "pruned stale context from group"); + } + } + + // Sync visibility and allowlist for each on-chain context. + for context_id in &on_chain_contexts { + // Sync visibility + match context_client + .query_context_visibility(*group_id, *context_id, protocol, network_id, contract_id) + .await + { + Ok(Some(vis)) => { + let mode_u8 = match vis.mode { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + let creator_bytes: [u8; 32] = vis.creator.as_bytes(); + if let Err(err) = + set_context_visibility(datastore, group_id, context_id, mode_u8, creator_bytes) + { + warn!( + ?group_id, %context_id, %err, + "failed to store context visibility" + ); + } + + // Sync allowlist: clear existing entries then re-populate from chain + let existing = + list_context_allowlist(datastore, group_id, context_id).unwrap_or_default(); + for member in &existing { + let _ = remove_from_context_allowlist(datastore, group_id, context_id, member); + } + + let mut al_offset = 0; + const AL_PAGE_SIZE: usize = 100; + loop { + match context_client + .query_context_allowlist( + *group_id, + *context_id, + protocol, + network_id, + contract_id, + al_offset, + AL_PAGE_SIZE, + ) + .await + { + Ok(page) => { + let page_len = page.len(); + for signer in &page { + let bytes: [u8; 32] = signer.as_bytes(); + let pk = PublicKey::from(bytes); + let _ = + add_to_context_allowlist(datastore, group_id, context_id, &pk); + } + if page_len < AL_PAGE_SIZE { + break; + } + al_offset += page_len; + } + Err(err) => { + warn!( + ?group_id, %context_id, %err, + "failed to query context allowlist" + ); + break; + } + } + } + } + Ok(None) => { + // No visibility data on-chain (context may not have visibility set yet) + } + Err(err) => { + warn!( + ?group_id, %context_id, %err, + "failed to query context visibility" + ); + } + } + } + + debug!( + ?group_id, + count = on_chain_contexts.len(), + "synced group contexts from contract" + ); + Ok(()) +} + +/// Paginates through `query_group_members()` and reconciles the local +/// member list with the on-chain state (upsert + prune). +async fn sync_group_members_from_contract( + datastore: &Store, + context_client: &ContextClient, + group_id: &ContextGroupId, + protocol: &str, + network_id: &str, + contract_id: &str, +) -> EyreResult<()> { + const PAGE_SIZE: usize = 100; + + let mut on_chain_members: HashSet<[u8; 32]> = HashSet::new(); + let mut offset = 0; + + loop { + let page = context_client + .query_group_members( + *group_id, + protocol, + network_id, + contract_id, + offset, + PAGE_SIZE, + ) + .await?; + + let page_len = page.len(); + for entry in page { + let identity_bytes: [u8; 32] = entry.identity.as_bytes(); + let pk = PublicKey::from(identity_bytes); + let role = match entry.role.as_str() { + "Admin" => GroupMemberRole::Admin, + _ => GroupMemberRole::Member, + }; + on_chain_members.insert(identity_bytes); + add_group_member(datastore, group_id, &pk, role)?; + set_member_capability(datastore, group_id, &pk, entry.capabilities)?; + } + + if page_len < PAGE_SIZE { + break; + } + offset += page_len; + } + + // Prune locally-stored members that no longer exist on-chain. + let local_members = list_group_members(datastore, group_id, 0, usize::MAX)?; + for (local_pk, _role) in local_members { + if !on_chain_members.contains(AsRef::<[u8; 32]>::as_ref(&local_pk)) { + remove_group_member(datastore, group_id, &local_pk)?; + debug!(?group_id, ?local_pk, "pruned stale member from group"); + } + } + + debug!( + ?group_id, + count = on_chain_members.len(), + "synced group members from contract" + ); + Ok(()) +} + +fn extract_application_id(app_json: &serde_json::Value) -> EyreResult { + use calimero_context_config::repr::{Repr, ReprBytes}; + use calimero_context_config::types::ApplicationId as ConfigApplicationId; + + let id_val = app_json + .get("id") + .ok_or_else(|| eyre::eyre!("missing 'id' in target_application"))?; + let repr: Repr = serde_json::from_value(id_val.clone()) + .map_err(|e| eyre::eyre!("invalid application id encoding: {e}"))?; + Ok(ApplicationId::from(repr.as_bytes())) +} + +// --------------------------------------------------------------------------- +// Group upgrade helpers +// --------------------------------------------------------------------------- + +/// Scans all GroupUpgradeKey entries and returns (group_id, upgrade_value) +/// pairs where status is InProgress. Used for crash recovery on startup. +pub fn enumerate_in_progress_upgrades( + store: &Store, +) -> EyreResult> { + let handle = store.handle(); + let start_key = GroupUpgradeKey::new([0u8; 32]); + + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + + let mut results = Vec::new(); + + for entry in first.into_iter().chain(iter.keys()) { + let key = entry?; + + if key.as_key().as_bytes()[0] != GROUP_UPGRADE_PREFIX { + break; + } + + if let Some(upgrade) = handle.get(&key)? { + if matches!(upgrade.status, GroupUpgradeStatus::InProgress { .. }) { + let group_id = ContextGroupId::from(key.group_id()); + results.push((group_id, upgrade)); + } + } + } + + Ok(results) +} + +// --------------------------------------------------------------------------- +// Permission helpers +// --------------------------------------------------------------------------- + +pub fn get_member_capability( + store: &Store, + group_id: &ContextGroupId, + member: &PublicKey, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupMemberCapability::new(group_id.to_bytes(), *member); + let value = handle.get(&key)?; + Ok(value.map(|v| v.capabilities)) +} + +pub fn set_member_capability( + store: &Store, + group_id: &ContextGroupId, + member: &PublicKey, + caps: u32, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupMemberCapability::new(group_id.to_bytes(), *member); + handle.put(&key, &GroupMemberCapabilityValue { capabilities: caps })?; + Ok(()) +} + +/// Returns (mode, creator_pk). mode: 0 = Open, 1 = Restricted. +pub fn get_context_visibility( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupContextVisibility::new(group_id.to_bytes(), *context_id); + let value = handle.get(&key)?; + Ok(value.map(|v| (v.mode, v.creator))) +} + +pub fn set_context_visibility( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + mode: u8, + creator: [u8; 32], +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupContextVisibility::new(group_id.to_bytes(), *context_id); + handle.put(&key, &GroupContextVisibilityValue { mode, creator })?; + Ok(()) +} + +pub fn check_context_allowlist( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + member: &PublicKey, +) -> EyreResult { + let handle = store.handle(); + let key = GroupContextAllowlist::new(group_id.to_bytes(), *context_id, *member); + // If the key exists (even with unit value), the member is on the allowlist + let value: Option<()> = handle.get(&key)?; + Ok(value.is_some()) +} + +pub fn add_to_context_allowlist( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + member: &PublicKey, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupContextAllowlist::new(group_id.to_bytes(), *context_id, *member); + handle.put(&key, &())?; + Ok(()) +} + +pub fn remove_from_context_allowlist( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + member: &PublicKey, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupContextAllowlist::new(group_id.to_bytes(), *context_id, *member); + handle.delete(&key)?; + Ok(()) +} + +pub fn list_context_allowlist( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = + GroupContextAllowlist::new(group_id_bytes, *context_id, PublicKey::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + + for entry in first.into_iter().chain(iter.keys()) { + let key = entry?; + + if key.as_key().as_bytes()[0] != GROUP_CONTEXT_ALLOWLIST_PREFIX { + break; + } + + if key.group_id() != group_id_bytes { + break; + } + + if key.context_id() != *context_id { + break; + } + + results.push(PublicKey::from(*key.member())); + } + + Ok(results) +} + +pub fn delete_context_visibility( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupContextVisibility::new(group_id.to_bytes(), *context_id); + handle.delete(&key)?; + Ok(()) +} + +pub fn clear_context_allowlist( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult<()> { + let members = list_context_allowlist(store, group_id, context_id)?; + for member in &members { + remove_from_context_allowlist(store, group_id, context_id, member)?; + } + Ok(()) +} + +pub fn enumerate_member_capabilities( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupMemberCapability::new(group_id_bytes, PublicKey::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + + if key.as_key().as_bytes()[0] != GROUP_MEMBER_CAPABILITY_PREFIX { + break; + } + + if key.group_id() != group_id_bytes { + break; + } + + let Some(val) = handle.get(&key)? else { + continue; + }; + + results.push((PublicKey::from(*key.identity()), val.capabilities)); + } + + Ok(results) +} + +pub fn enumerate_context_visibilities( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let group_id_bytes: [u8; 32] = group_id.to_bytes(); + let start_key = GroupContextVisibility::new(group_id_bytes, ContextId::from([0u8; 32])); + let mut iter = handle.iter::()?; + let first = iter.seek(start_key).transpose(); + let mut results = Vec::new(); + + for key_result in first.into_iter().chain(iter.keys()) { + let key = key_result?; + + if key.as_key().as_bytes()[0] != GROUP_CONTEXT_VISIBILITY_PREFIX { + break; + } + + if key.group_id() != group_id_bytes { + break; + } + + let Some(val) = handle.get(&key)? else { + continue; + }; + + results.push((ContextId::from(*key.context_id()), val.mode, val.creator)); + } + + Ok(results) +} + +pub fn enumerate_contexts_with_allowlists( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult)>> { + let context_ids = enumerate_group_contexts(store, group_id, 0, usize::MAX)?; + let mut results = Vec::new(); + + for context_id in context_ids { + let members = list_context_allowlist(store, group_id, &context_id)?; + if !members.is_empty() { + results.push((context_id, members)); + } + } + + Ok(results) +} + +pub fn get_default_capabilities( + store: &Store, + group_id: &ContextGroupId, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupDefaultCaps::new(group_id.to_bytes()); + let value = handle.get(&key)?; + Ok(value.map(|v| v.capabilities)) +} + +pub fn set_default_capabilities( + store: &Store, + group_id: &ContextGroupId, + caps: u32, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupDefaultCaps::new(group_id.to_bytes()); + handle.put(&key, &GroupDefaultCapsValue { capabilities: caps })?; + Ok(()) +} + +pub fn get_default_visibility(store: &Store, group_id: &ContextGroupId) -> EyreResult> { + let handle = store.handle(); + let key = GroupDefaultVis::new(group_id.to_bytes()); + let value = handle.get(&key)?; + Ok(value.map(|v| v.mode)) +} + +pub fn set_default_visibility( + store: &Store, + group_id: &ContextGroupId, + mode: u8, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupDefaultVis::new(group_id.to_bytes()); + handle.put(&key, &GroupDefaultVisValue { mode })?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Per-context migration tracking +// --------------------------------------------------------------------------- + +/// Returns the migration method name that was last successfully applied to +/// `context_id` within `group_id`, or `None` if no migration has been recorded. +pub fn get_context_last_migration( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, +) -> EyreResult> { + let handle = store.handle(); + let key = GroupContextLastMigration::new(group_id.to_bytes(), (*context_id).into()); + Ok(handle + .get(&key)? + .map(|v: GroupContextLastMigrationValue| v.method)) +} + +/// Records that `method` was successfully applied to `context_id` within +/// `group_id`. Subsequent calls to `maybe_lazy_upgrade` will skip this +/// migration for this context unless a different method is configured. +pub fn set_context_last_migration( + store: &Store, + group_id: &ContextGroupId, + context_id: &ContextId, + method: &str, +) -> EyreResult<()> { + let mut handle = store.handle(); + let key = GroupContextLastMigration::new(group_id.to_bytes(), (*context_id).into()); + handle.put( + &key, + &GroupContextLastMigrationValue { + method: method.to_owned(), + }, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use calimero_context_config::types::ContextGroupId; + use calimero_primitives::application::ApplicationId; + use calimero_primitives::context::{ContextId, GroupMemberRole, UpgradePolicy}; + use calimero_primitives::identity::PublicKey; + use calimero_store::db::InMemoryDB; + use calimero_store::key::{GroupMetaValue, GroupUpgradeStatus, GroupUpgradeValue}; + use calimero_store::Store; + + use super::*; + + fn test_store() -> Store { + Store::new(Arc::new(InMemoryDB::owned())) + } + + fn test_group_id() -> ContextGroupId { + ContextGroupId::from([0xAA; 32]) + } + + fn test_meta() -> GroupMetaValue { + GroupMetaValue { + app_key: [0xBB; 32], + target_application_id: ApplicationId::from([0xCC; 32]), + upgrade_policy: UpgradePolicy::Automatic, + created_at: 1_700_000_000, + admin_identity: PublicKey::from([0x01; 32]), + migration: None, + } + } + + // ----------------------------------------------------------------------- + // Group meta tests + // ----------------------------------------------------------------------- + + #[test] + fn save_load_delete_group_meta() { + let store = test_store(); + let gid = test_group_id(); + let meta = test_meta(); + + assert!(load_group_meta(&store, &gid).unwrap().is_none()); + + save_group_meta(&store, &gid, &meta).unwrap(); + let loaded = load_group_meta(&store, &gid).unwrap().unwrap(); + assert_eq!(loaded.app_key, meta.app_key); + assert_eq!(loaded.target_application_id, meta.target_application_id); + + delete_group_meta(&store, &gid).unwrap(); + assert!(load_group_meta(&store, &gid).unwrap().is_none()); + } + + // ----------------------------------------------------------------------- + // Member tests + // ----------------------------------------------------------------------- + + #[test] + fn add_and_check_membership() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x01; 32]); + + assert!(!check_group_membership(&store, &gid, &pk).unwrap()); + + add_group_member(&store, &gid, &pk, GroupMemberRole::Admin).unwrap(); + assert!(check_group_membership(&store, &gid, &pk).unwrap()); + assert!(is_group_admin(&store, &gid, &pk).unwrap()); + } + + #[test] + fn remove_member() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x02; 32]); + + add_group_member(&store, &gid, &pk, GroupMemberRole::Member).unwrap(); + assert!(check_group_membership(&store, &gid, &pk).unwrap()); + + remove_group_member(&store, &gid, &pk).unwrap(); + assert!(!check_group_membership(&store, &gid, &pk).unwrap()); + } + + #[test] + fn get_member_role() { + let store = test_store(); + let gid = test_group_id(); + let admin = PublicKey::from([0x01; 32]); + let member = PublicKey::from([0x02; 32]); + + add_group_member(&store, &gid, &admin, GroupMemberRole::Admin).unwrap(); + add_group_member(&store, &gid, &member, GroupMemberRole::Member).unwrap(); + + assert_eq!( + get_group_member_role(&store, &gid, &admin).unwrap(), + Some(GroupMemberRole::Admin) + ); + assert_eq!( + get_group_member_role(&store, &gid, &member).unwrap(), + Some(GroupMemberRole::Member) + ); + assert!(!is_group_admin(&store, &gid, &member).unwrap()); + } + + #[test] + fn require_group_admin_rejects_non_admin() { + let store = test_store(); + let gid = test_group_id(); + let member = PublicKey::from([0x03; 32]); + + add_group_member(&store, &gid, &member, GroupMemberRole::Member).unwrap(); + assert!(require_group_admin(&store, &gid, &member).is_err()); + } + + #[test] + fn count_members_and_admins() { + let store = test_store(); + let gid = test_group_id(); + + assert_eq!(count_group_members(&store, &gid).unwrap(), 0); + assert_eq!(count_group_admins(&store, &gid).unwrap(), 0); + + add_group_member( + &store, + &gid, + &PublicKey::from([0x01; 32]), + GroupMemberRole::Admin, + ) + .unwrap(); + add_group_member( + &store, + &gid, + &PublicKey::from([0x02; 32]), + GroupMemberRole::Member, + ) + .unwrap(); + add_group_member( + &store, + &gid, + &PublicKey::from([0x03; 32]), + GroupMemberRole::Admin, + ) + .unwrap(); + + assert_eq!(count_group_members(&store, &gid).unwrap(), 3); + assert_eq!(count_group_admins(&store, &gid).unwrap(), 2); + } + + #[test] + fn list_members_with_offset_and_limit() { + let store = test_store(); + let gid = test_group_id(); + + for i in 0u8..5 { + let mut pk_bytes = [0u8; 32]; + pk_bytes[0] = i; + add_group_member( + &store, + &gid, + &PublicKey::from(pk_bytes), + GroupMemberRole::Member, + ) + .unwrap(); + } + + let all = list_group_members(&store, &gid, 0, 100).unwrap(); + assert_eq!(all.len(), 5); + + let page = list_group_members(&store, &gid, 1, 2).unwrap(); + assert_eq!(page.len(), 2); + } + + // ----------------------------------------------------------------------- + // Signing key tests + // ----------------------------------------------------------------------- + + #[test] + fn store_and_get_signing_key() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x10; 32]); + let sk = [0xAA; 32]; + + assert!(get_group_signing_key(&store, &gid, &pk).unwrap().is_none()); + + store_group_signing_key(&store, &gid, &pk, &sk).unwrap(); + let loaded = get_group_signing_key(&store, &gid, &pk).unwrap().unwrap(); + assert_eq!(loaded, sk); + } + + #[test] + fn delete_signing_key() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x10; 32]); + let sk = [0xAA; 32]; + + store_group_signing_key(&store, &gid, &pk, &sk).unwrap(); + delete_group_signing_key(&store, &gid, &pk).unwrap(); + assert!(get_group_signing_key(&store, &gid, &pk).unwrap().is_none()); + } + + #[test] + fn require_signing_key_fails_when_missing() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x10; 32]); + + assert!(require_group_signing_key(&store, &gid, &pk).is_err()); + } + + #[test] + fn delete_all_group_signing_keys_removes_all() { + let store = test_store(); + let gid = test_group_id(); + let pk1 = PublicKey::from([0x10; 32]); + let pk2 = PublicKey::from([0x11; 32]); + + store_group_signing_key(&store, &gid, &pk1, &[0xAA; 32]).unwrap(); + store_group_signing_key(&store, &gid, &pk2, &[0xBB; 32]).unwrap(); + + delete_all_group_signing_keys(&store, &gid).unwrap(); + + assert!(get_group_signing_key(&store, &gid, &pk1).unwrap().is_none()); + assert!(get_group_signing_key(&store, &gid, &pk2).unwrap().is_none()); + } + + // ----------------------------------------------------------------------- + // Context-group index tests + // ----------------------------------------------------------------------- + + #[test] + fn register_and_unregister_context() { + let store = test_store(); + let gid = test_group_id(); + let cid = ContextId::from([0x11; 32]); + + assert!(get_group_for_context(&store, &cid).unwrap().is_none()); + + register_context_in_group(&store, &gid, &cid).unwrap(); + assert_eq!(get_group_for_context(&store, &cid).unwrap().unwrap(), gid); + + unregister_context_from_group(&store, &gid, &cid).unwrap(); + assert!(get_group_for_context(&store, &cid).unwrap().is_none()); + } + + #[test] + fn re_register_context_cleans_old_group() { + let store = test_store(); + let gid1 = ContextGroupId::from([0x01; 32]); + let gid2 = ContextGroupId::from([0x02; 32]); + let cid = ContextId::from([0x11; 32]); + + register_context_in_group(&store, &gid1, &cid).unwrap(); + assert_eq!(count_group_contexts(&store, &gid1).unwrap(), 1); + + register_context_in_group(&store, &gid2, &cid).unwrap(); + assert_eq!(count_group_contexts(&store, &gid1).unwrap(), 0); + assert_eq!(count_group_contexts(&store, &gid2).unwrap(), 1); + assert_eq!(get_group_for_context(&store, &cid).unwrap().unwrap(), gid2); + } + + #[test] + fn enumerate_and_count_contexts() { + let store = test_store(); + let gid = test_group_id(); + + for i in 0u8..4 { + let mut cid_bytes = [0u8; 32]; + cid_bytes[0] = i; + register_context_in_group(&store, &gid, &ContextId::from(cid_bytes)).unwrap(); + } + + assert_eq!(count_group_contexts(&store, &gid).unwrap(), 4); + + let page = enumerate_group_contexts(&store, &gid, 1, 2).unwrap(); + assert_eq!(page.len(), 2); + } + + // ----------------------------------------------------------------------- + // Upgrade tests + // ----------------------------------------------------------------------- + + #[test] + fn save_load_delete_upgrade() { + let store = test_store(); + let gid = test_group_id(); + + assert!(load_group_upgrade(&store, &gid).unwrap().is_none()); + + let upgrade = GroupUpgradeValue { + from_version: "1.0.0".to_owned(), + to_version: "2.0.0".to_owned(), + migration: None, + initiated_at: 1_700_000_000, + initiated_by: PublicKey::from([0x01; 32]), + status: GroupUpgradeStatus::InProgress { + total: 5, + completed: 0, + failed: 0, + }, + }; + + save_group_upgrade(&store, &gid, &upgrade).unwrap(); + let loaded = load_group_upgrade(&store, &gid).unwrap().unwrap(); + assert_eq!(loaded.from_version, "1.0.0"); + assert_eq!(loaded.to_version, "2.0.0"); + + delete_group_upgrade(&store, &gid).unwrap(); + assert!(load_group_upgrade(&store, &gid).unwrap().is_none()); + } + + #[test] + fn enumerate_in_progress_upgrades_filters_completed() { + let store = test_store(); + let gid_in_progress = ContextGroupId::from([0x01; 32]); + let gid_completed = ContextGroupId::from([0x02; 32]); + + save_group_upgrade( + &store, + &gid_in_progress, + &GroupUpgradeValue { + from_version: "1.0.0".to_owned(), + to_version: "2.0.0".to_owned(), + migration: None, + initiated_at: 1_700_000_000, + initiated_by: PublicKey::from([0x01; 32]), + status: GroupUpgradeStatus::InProgress { + total: 5, + completed: 2, + failed: 0, + }, + }, + ) + .unwrap(); + + save_group_upgrade( + &store, + &gid_completed, + &GroupUpgradeValue { + from_version: "1.0.0".to_owned(), + to_version: "2.0.0".to_owned(), + migration: None, + initiated_at: 1_700_000_000, + initiated_by: PublicKey::from([0x01; 32]), + status: GroupUpgradeStatus::Completed { + completed_at: Some(1_700_001_000), + }, + }, + ) + .unwrap(); + + let results = enumerate_in_progress_upgrades(&store).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, gid_in_progress); + } + + // ----------------------------------------------------------------------- + // enumerate_all_groups — prefix guard regression test + // ----------------------------------------------------------------------- + + /// Regression test: `enumerate_all_groups` must stop at GroupMeta keys and + /// not spill into adjacent GroupMember keys (prefix 0x21). Before the fix, + /// the function would attempt to deserialise a `GroupMemberRole` value as + /// `GroupMetaValue`, panicking with "failed to fill whole buffer". + #[test] + fn enumerate_all_groups_stops_before_member_keys() { + let store = test_store(); + let gid = test_group_id(); + let meta = test_meta(); + let member = PublicKey::from([0x10; 32]); + + save_group_meta(&store, &gid, &meta).unwrap(); + // Add a group member — this writes a GroupMember key (prefix 0x21) + // into the same column, right after GroupMeta keys (prefix 0x20). + add_group_member(&store, &gid, &member, GroupMemberRole::Admin).unwrap(); + + // Must return exactly one group without panicking. + let groups = enumerate_all_groups(&store, 0, 100).unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, gid.to_bytes()); + } + + #[test] + fn enumerate_all_groups_multiple_groups_with_members() { + let store = test_store(); + let gid1 = ContextGroupId::from([0x01; 32]); + let gid2 = ContextGroupId::from([0x02; 32]); + let meta = test_meta(); + + save_group_meta(&store, &gid1, &meta).unwrap(); + save_group_meta(&store, &gid2, &meta).unwrap(); + add_group_member( + &store, + &gid1, + &PublicKey::from([0xAA; 32]), + GroupMemberRole::Admin, + ) + .unwrap(); + add_group_member( + &store, + &gid2, + &PublicKey::from([0xBB; 32]), + GroupMemberRole::Member, + ) + .unwrap(); + + let groups = enumerate_all_groups(&store, 0, 100).unwrap(); + assert_eq!(groups.len(), 2); + + // Pagination + let page = enumerate_all_groups(&store, 1, 1).unwrap(); + assert_eq!(page.len(), 1); + } + + // ----------------------------------------------------------------------- + // extract_application_id — base58 decoding regression test + // ----------------------------------------------------------------------- + + /// Regression test: `extract_application_id` must decode the `id` field + /// using base58 (via `Repr`), not hex. Before the fix, + /// `hex::decode` was called on a base58 string, producing + /// "Invalid character 'g' at position 1" errors at runtime. + #[test] + fn extract_application_id_decodes_base58() { + // Repr<[u8; 32]> serialises as base58, matching what the NEAR contract + // returns for Repr with the same underlying bytes. + use calimero_context_config::repr::Repr; + + let raw: [u8; 32] = [0xDE; 32]; + let encoded = Repr::new(raw).to_string(); // base58 string + + let json = serde_json::json!({ "id": encoded }); + let result = extract_application_id(&json).unwrap(); + assert_eq!(*result, raw); + } + + #[test] + fn extract_application_id_rejects_hex() { + // A hex string decodes to ~46 bytes via base58, causing a length + // mismatch against the required 32-byte ApplicationId. + let hex_str = hex::encode([0xDE; 32]); + let json = serde_json::json!({ "id": hex_str }); + assert!(extract_application_id(&json).is_err()); + } + + #[test] + fn extract_application_id_missing_field_returns_error() { + let json = serde_json::json!({}); + assert!(extract_application_id(&json).is_err()); + } + + // ----------------------------------------------------------------------- + // Member capability tests + // ----------------------------------------------------------------------- + + #[test] + fn set_and_get_member_capability() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x10; 32]); + + // No capability stored yet + assert!(get_member_capability(&store, &gid, &pk).unwrap().is_none()); + + // Set capabilities + set_member_capability(&store, &gid, &pk, 0b101).unwrap(); + let caps = get_member_capability(&store, &gid, &pk).unwrap().unwrap(); + assert_eq!(caps, 0b101); + + // Update capabilities + set_member_capability(&store, &gid, &pk, 0b111).unwrap(); + let caps = get_member_capability(&store, &gid, &pk).unwrap().unwrap(); + assert_eq!(caps, 0b111); + } + + #[test] + fn capability_zero_means_no_permissions() { + let store = test_store(); + let gid = test_group_id(); + let pk = PublicKey::from([0x11; 32]); + + set_member_capability(&store, &gid, &pk, 0).unwrap(); + let caps = get_member_capability(&store, &gid, &pk).unwrap().unwrap(); + assert_eq!(caps, 0); + // All capability bits are off + assert_eq!(caps & (1 << 0), 0); // CAN_CREATE_CONTEXT + assert_eq!(caps & (1 << 1), 0); // CAN_INVITE_MEMBERS + assert_eq!(caps & (1 << 2), 0); // CAN_JOIN_OPEN_CONTEXTS + } + + #[test] + fn capabilities_isolated_per_member() { + let store = test_store(); + let gid = test_group_id(); + let alice = PublicKey::from([0x12; 32]); + let bob = PublicKey::from([0x13; 32]); + + set_member_capability(&store, &gid, &alice, 0b001).unwrap(); + set_member_capability(&store, &gid, &bob, 0b110).unwrap(); + + assert_eq!( + get_member_capability(&store, &gid, &alice) + .unwrap() + .unwrap(), + 0b001 + ); + assert_eq!( + get_member_capability(&store, &gid, &bob).unwrap().unwrap(), + 0b110 + ); + } + + // ----------------------------------------------------------------------- + // Context visibility tests + // ----------------------------------------------------------------------- + + #[test] + fn set_and_get_context_visibility() { + let store = test_store(); + let gid = test_group_id(); + let ctx = ContextId::from([0x20; 32]); + let creator: [u8; 32] = [0x01; 32]; + + // No visibility stored yet + assert!(get_context_visibility(&store, &gid, &ctx) + .unwrap() + .is_none()); + + // Set to Open (0) + set_context_visibility(&store, &gid, &ctx, 0, creator).unwrap(); + let (mode, stored_creator) = get_context_visibility(&store, &gid, &ctx).unwrap().unwrap(); + assert_eq!(mode, 0); + assert_eq!(stored_creator, creator); + + // Update to Restricted (1) + set_context_visibility(&store, &gid, &ctx, 1, creator).unwrap(); + let (mode, _) = get_context_visibility(&store, &gid, &ctx).unwrap().unwrap(); + assert_eq!(mode, 1); + } + + #[test] + fn visibility_isolated_per_context() { + let store = test_store(); + let gid = test_group_id(); + let ctx1 = ContextId::from([0x21; 32]); + let ctx2 = ContextId::from([0x22; 32]); + let creator: [u8; 32] = [0x01; 32]; + + set_context_visibility(&store, &gid, &ctx1, 0, creator).unwrap(); + set_context_visibility(&store, &gid, &ctx2, 1, creator).unwrap(); + + let (mode1, _) = get_context_visibility(&store, &gid, &ctx1) + .unwrap() + .unwrap(); + let (mode2, _) = get_context_visibility(&store, &gid, &ctx2) + .unwrap() + .unwrap(); + assert_eq!(mode1, 0); + assert_eq!(mode2, 1); + } + + // ----------------------------------------------------------------------- + // Context allowlist tests + // ----------------------------------------------------------------------- + + #[test] + fn add_check_remove_context_allowlist() { + let store = test_store(); + let gid = test_group_id(); + let ctx = ContextId::from([0x30; 32]); + let member = PublicKey::from([0x31; 32]); + + // Not on allowlist initially + assert!(!check_context_allowlist(&store, &gid, &ctx, &member).unwrap()); + + // Add to allowlist + add_to_context_allowlist(&store, &gid, &ctx, &member).unwrap(); + assert!(check_context_allowlist(&store, &gid, &ctx, &member).unwrap()); + + // Remove from allowlist + remove_from_context_allowlist(&store, &gid, &ctx, &member).unwrap(); + assert!(!check_context_allowlist(&store, &gid, &ctx, &member).unwrap()); + } + + #[test] + fn list_context_allowlist_returns_all_members() { + let store = test_store(); + let gid = test_group_id(); + let ctx = ContextId::from([0x32; 32]); + let m1 = PublicKey::from([0x33; 32]); + let m2 = PublicKey::from([0x34; 32]); + let m3 = PublicKey::from([0x35; 32]); + + add_to_context_allowlist(&store, &gid, &ctx, &m1).unwrap(); + add_to_context_allowlist(&store, &gid, &ctx, &m2).unwrap(); + add_to_context_allowlist(&store, &gid, &ctx, &m3).unwrap(); + + let members = list_context_allowlist(&store, &gid, &ctx).unwrap(); + assert_eq!(members.len(), 3); + assert!(members.contains(&m1)); + assert!(members.contains(&m2)); + assert!(members.contains(&m3)); + } + + #[test] + fn list_context_allowlist_isolated_per_context() { + let store = test_store(); + let gid = test_group_id(); + let ctx1 = ContextId::from([0x36; 32]); + let ctx2 = ContextId::from([0x37; 32]); + let m1 = PublicKey::from([0x38; 32]); + let m2 = PublicKey::from([0x39; 32]); + + add_to_context_allowlist(&store, &gid, &ctx1, &m1).unwrap(); + add_to_context_allowlist(&store, &gid, &ctx2, &m2).unwrap(); + + let ctx1_members = list_context_allowlist(&store, &gid, &ctx1).unwrap(); + assert_eq!(ctx1_members.len(), 1); + assert!(ctx1_members.contains(&m1)); + + let ctx2_members = list_context_allowlist(&store, &gid, &ctx2).unwrap(); + assert_eq!(ctx2_members.len(), 1); + assert!(ctx2_members.contains(&m2)); + } + + #[test] + fn allowlist_add_idempotent() { + let store = test_store(); + let gid = test_group_id(); + let ctx = ContextId::from([0x3A; 32]); + let member = PublicKey::from([0x3B; 32]); + + add_to_context_allowlist(&store, &gid, &ctx, &member).unwrap(); + add_to_context_allowlist(&store, &gid, &ctx, &member).unwrap(); + + let members = list_context_allowlist(&store, &gid, &ctx).unwrap(); + assert_eq!(members.len(), 1); + } + + // ----------------------------------------------------------------------- + // Default capabilities and visibility tests + // ----------------------------------------------------------------------- + + #[test] + fn set_and_get_default_capabilities() { + let store = test_store(); + let gid = test_group_id(); + + assert!(get_default_capabilities(&store, &gid).unwrap().is_none()); + + set_default_capabilities(&store, &gid, 0b100).unwrap(); + assert_eq!( + get_default_capabilities(&store, &gid).unwrap().unwrap(), + 0b100 + ); + + // Update + set_default_capabilities(&store, &gid, 0b111).unwrap(); + assert_eq!( + get_default_capabilities(&store, &gid).unwrap().unwrap(), + 0b111 + ); + } + + #[test] + fn set_and_get_default_visibility() { + let store = test_store(); + let gid = test_group_id(); + + assert!(get_default_visibility(&store, &gid).unwrap().is_none()); + + // Open = 0 + set_default_visibility(&store, &gid, 0).unwrap(); + assert_eq!(get_default_visibility(&store, &gid).unwrap().unwrap(), 0); + + // Restricted = 1 + set_default_visibility(&store, &gid, 1).unwrap(); + assert_eq!(get_default_visibility(&store, &gid).unwrap().unwrap(), 1); + } + + #[test] + fn defaults_isolated_per_group() { + let store = test_store(); + let g1 = ContextGroupId::from([0x40; 32]); + let g2 = ContextGroupId::from([0x41; 32]); + + set_default_capabilities(&store, &g1, 0b001).unwrap(); + set_default_capabilities(&store, &g2, 0b110).unwrap(); + set_default_visibility(&store, &g1, 0).unwrap(); + set_default_visibility(&store, &g2, 1).unwrap(); + + assert_eq!( + get_default_capabilities(&store, &g1).unwrap().unwrap(), + 0b001 + ); + assert_eq!( + get_default_capabilities(&store, &g2).unwrap().unwrap(), + 0b110 + ); + assert_eq!(get_default_visibility(&store, &g1).unwrap().unwrap(), 0); + assert_eq!(get_default_visibility(&store, &g2).unwrap().unwrap(), 1); + } +} diff --git a/crates/context/src/handlers.rs b/crates/context/src/handlers.rs index eef592f6fa..38e63f23b5 100644 --- a/crates/context/src/handlers.rs +++ b/crates/context/src/handlers.rs @@ -4,12 +4,52 @@ use calimero_utils_actix::adapters::ActorExt; use crate::ContextManager; +pub mod add_group_members; +pub mod broadcast_group_aliases; +pub mod broadcast_group_local_state; pub mod create_context; +pub mod create_group; +pub mod create_group_invitation; pub mod delete_context; +pub mod delete_group; +pub mod detach_context_from_group; pub mod execute; +pub mod get_context_allowlist; +pub mod get_context_visibility; +pub mod get_group_for_context; +pub mod get_group_info; +pub mod get_group_upgrade_status; +pub mod get_member_capabilities; pub mod join_context; +pub mod join_group; +pub mod join_group_context; +pub mod list_all_groups; +pub mod list_group_contexts; +pub mod list_group_members; +pub mod manage_context_allowlist; +pub mod remove_group_members; +pub mod retry_group_upgrade; +pub mod set_context_visibility; +pub mod set_default_capabilities; +pub mod set_default_visibility; +pub mod set_group_alias; +pub mod set_member_alias; +pub mod set_member_capabilities; +pub mod store_context_alias; +pub mod store_context_allowlist; +pub mod store_context_visibility; +pub mod store_default_capabilities; +pub mod store_default_visibility; +pub mod store_group_alias; +pub mod store_group_context; +pub mod store_member_alias; +pub mod store_member_capability; pub mod sync; +pub mod sync_group; pub mod update_application; +pub mod update_group_settings; +pub mod update_member_role; +pub mod upgrade_group; mod utils; impl Handler for ContextManager { @@ -42,6 +82,126 @@ impl Handler for ContextManager { ContextMessage::Sync { request, outcome } => { self.forward_handler(ctx, request, outcome) } + ContextMessage::CreateGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::DeleteGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::AddGroupMembers { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::RemoveGroupMembers { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetGroupInfo { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::ListGroupMembers { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::ListGroupContexts { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::UpgradeGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetGroupUpgradeStatus { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::RetryGroupUpgrade { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::CreateGroupInvitation { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::JoinGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::ListAllGroups { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::UpdateGroupSettings { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::UpdateMemberRole { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::DetachContextFromGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetGroupForContext { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SyncGroup { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::JoinGroupContext { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetMemberCapabilities { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetMemberCapabilities { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetContextVisibility { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetContextVisibility { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::ManageContextAllowlist { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::GetContextAllowlist { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetDefaultCapabilities { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetDefaultVisibility { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreContextAlias { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::BroadcastGroupAliases { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::BroadcastGroupLocalState { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreMemberCapability { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreDefaultCapabilities { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreContextVisibility { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreDefaultVisibility { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreContextAllowlist { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetMemberAlias { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreMemberAlias { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::SetGroupAlias { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreGroupAlias { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } + ContextMessage::StoreGroupContext { request, outcome } => { + self.forward_handler(ctx, request, outcome) + } } } } diff --git a/crates/context/src/handlers/add_group_members.rs b/crates/context/src/handlers/add_group_members.rs new file mode 100644 index 0000000000..d5dcd446a6 --- /dev/null +++ b/crates/context/src/handlers/add_group_members.rs @@ -0,0 +1,97 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_primitives::group::AddGroupMembersRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + AddGroupMembersRequest { + group_id, + members, + requester, + }: AddGroupMembersRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // Sync validation + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group not found"); + } + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + // Auto-store signing key for future use + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let datastore = self.datastore.clone(); + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + let signer_ids: Vec = members + .iter() + .map(|(pk, _)| pk.rt()) + .collect::, _>>()?; + group_client.add_group_members(&signer_ids).await?; + } + + for (identity, role) in &members { + group_store::add_group_member(&datastore, &group_id, identity, role.clone())?; + } + + info!(?group_id, count = members.len(), %requester, "members added to group"); + + let _ = node_client + .broadcast_group_mutation(group_id.to_bytes(), GroupMutationKind::MembersAdded) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/broadcast_group_aliases.rs b/crates/context/src/handlers/broadcast_group_aliases.rs new file mode 100644 index 0000000000..1d83d37a60 --- /dev/null +++ b/crates/context/src/handlers/broadcast_group_aliases.rs @@ -0,0 +1,47 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::BroadcastGroupAliasesRequest; +use calimero_node_primitives::sync::GroupMutationKind; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + BroadcastGroupAliasesRequest { group_id }: BroadcastGroupAliasesRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let entries = match group_store::enumerate_group_contexts_with_aliases( + &self.datastore, + &group_id, + 0, + usize::MAX, + ) { + Ok(e) => e, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let node_client = self.node_client.clone(); + let group_id_bytes = group_id.to_bytes(); + + ActorResponse::r#async( + async move { + for (context_id, alias) in entries { + let Some(alias) = alias else { continue }; + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::ContextAliasSet { + context_id: *context_id, + alias, + }, + ) + .await; + } + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/broadcast_group_local_state.rs b/crates/context/src/handlers/broadcast_group_local_state.rs new file mode 100644 index 0000000000..08a1d8d53b --- /dev/null +++ b/crates/context/src/handlers/broadcast_group_local_state.rs @@ -0,0 +1,155 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::BroadcastGroupLocalStateRequest; +use calimero_node_primitives::sync::GroupMutationKind; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + BroadcastGroupLocalStateRequest { group_id }: BroadcastGroupLocalStateRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let member_caps = + match group_store::enumerate_member_capabilities(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let default_caps = match group_store::get_default_capabilities(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let context_vis = + match group_store::enumerate_context_visibilities(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let default_vis = match group_store::get_default_visibility(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let allowlists = + match group_store::enumerate_contexts_with_allowlists(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let member_aliases = match group_store::enumerate_member_aliases(&self.datastore, &group_id) + { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let group_alias = match group_store::get_group_alias(&self.datastore, &group_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + let contexts = match group_store::enumerate_group_contexts( + &self.datastore, + &group_id, + 0, + usize::MAX, + ) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let node_client = self.node_client.clone(); + let group_id_bytes = group_id.to_bytes(); + + ActorResponse::r#async( + async move { + for (member, capabilities) in member_caps { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::MemberCapabilitySet { + member: *member, + capabilities, + }, + ) + .await; + } + + if let Some(capabilities) = default_caps { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::DefaultCapabilitiesSet { capabilities }, + ) + .await; + } + + for (context_id, mode, creator) in context_vis { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::ContextVisibilitySet { + context_id: *context_id, + mode, + creator, + }, + ) + .await; + } + + if let Some(mode) = default_vis { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::DefaultVisibilitySet { mode }, + ) + .await; + } + + for (context_id, members) in allowlists { + let members_raw: Vec<[u8; 32]> = members.iter().map(|pk| **pk).collect(); + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::ContextAllowlistSet { + context_id: *context_id, + members: members_raw, + }, + ) + .await; + } + + for (member, alias) in member_aliases { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::MemberAliasSet { + member: *member, + alias, + }, + ) + .await; + } + + if let Some(alias) = group_alias { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::GroupAliasSet { alias }, + ) + .await; + } + + for context_id in contexts { + let _ = node_client + .broadcast_group_mutation( + group_id_bytes, + GroupMutationKind::ContextRegistered { + context_id: *context_id, + }, + ) + .await; + } + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/create_context.rs b/crates/context/src/handlers/create_context.rs index 5d6f59c89d..19238f1592 100644 --- a/crates/context/src/handlers/create_context.rs +++ b/crates/context/src/handlers/create_context.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use actix::{ActorResponse, ActorTryFutureExt, Handler, Message, WrapFuture}; use calimero_context_config::client::config::ClientConfig as ExternalClientConfig; use calimero_context_config::client::utils::humanize_iter; +use calimero_context_config::types::ContextGroupId; +use calimero_context_config::MemberCapabilities; use calimero_context_primitives::client::ContextClient; use calimero_context_primitives::messages::{CreateContextRequest, CreateContextResponse}; use calimero_node_primitives::client::NodeClient; @@ -23,7 +25,7 @@ use tracing::{debug, warn}; use super::execute::execute; use super::execute::storage::{ContextPrivateStorage, ContextStorage}; -use crate::{ContextManager, ContextMeta}; +use crate::{group_store, ContextManager, ContextMeta}; impl Handler for ContextManager { type Result = ActorResponse::Result>; @@ -36,9 +38,17 @@ impl Handler for ContextManager { application_id, identity_secret, init_params, + group_id, + alias, }: CreateContextRequest, _ctx: &mut Self::Context, ) -> Self::Result { + let identity_secret = identity_secret.or_else(|| { + group_id.as_ref()?; + let (_, sk) = self.node_group_identity()?; + Some(PrivateKey::from(sk)) + }); + let prepared = match Prepared::new( &self.node_client, &self.context_client, @@ -49,6 +59,9 @@ impl Handler for ContextManager { seed, &application_id, identity_secret, + group_id, + alias, + &self.datastore, ) { Ok(res) => res, Err(err) => return ActorResponse::reply(Err(err)), @@ -62,6 +75,8 @@ impl Handler for ContextManager { identity, identity_secret, sender_key, + group_id, + alias, } = prepared; let guard = context @@ -72,7 +87,7 @@ impl Handler for ContextManager { let context_meta = context.meta.clone(); - let module_task = self.get_module(application_id); + let module_task = self.get_module(application.id); let context_meta_for_map_ok = context_meta.clone(); let context_meta_for_map_err = context_meta.clone(); @@ -94,6 +109,8 @@ impl Handler for ContextManager { sender_key, init_params, guard, + group_id, + alias, ) .into_actor(act) }) @@ -128,6 +145,8 @@ struct Prepared<'a> { identity: PublicKey, identity_secret: PrivateKey, sender_key: PrivateKey, + group_id: Option, + alias: Option, } impl Prepared<'_> { @@ -141,6 +160,9 @@ impl Prepared<'_> { seed: Option<[u8; 32]>, application_id: &ApplicationId, identity_secret: Option, + group_id: Option, + alias: Option, + datastore: &Store, ) -> eyre::Result { let Some(external_config) = external_config.params.get(&protocol) else { bail!( @@ -161,6 +183,42 @@ impl Prepared<'_> { // ^^ not used for context creation -- }; + let mut effective_app_id = *application_id; + if let Some(ref gid) = group_id { + let meta = + group_store::load_group_meta(datastore, gid)?.ok_or_eyre("group not found")?; + + let identity_pk = identity_secret + .as_ref() + .ok_or_eyre("identity_secret required for group context creation")? + .public_key(); + + if !group_store::check_group_membership(datastore, gid, &identity_pk)? { + bail!("identity is not a member of group '{gid:?}'"); + } + + if !group_store::is_group_admin_or_has_capability( + datastore, + gid, + &identity_pk, + MemberCapabilities::CAN_CREATE_CONTEXT, + )? { + bail!( + "identity lacks permission to create a context in group '{gid:?}' \ + (not an admin and CAN_CREATE_CONTEXT is not set)" + ); + } + + if effective_app_id != meta.target_application_id { + warn!( + requested=?effective_app_id, + group_target=?meta.target_application_id, + "overriding application_id with group target" + ); + effective_app_id = meta.target_application_id; + } + } + let mut rng = rand::thread_rng(); let sender_key = PrivateKey::random(&mut rng); @@ -206,10 +264,10 @@ impl Prepared<'_> { .flatten() .ok_or_eyre("failed to derive a context id after 5 tries")?; - let application = match applications.entry(*application_id) { + let application = match applications.entry(effective_app_id) { btree_map::Entry::Vacant(vacant) => { let application = node_client - .get_application(application_id)? + .get_application(&effective_app_id)? .ok_or_eyre("application not found")?; vacant.insert(application) @@ -219,7 +277,7 @@ impl Prepared<'_> { let identity = identity_secret.public_key(); - let meta = Context::new(context_id, *application_id, Hash::default()); + let meta = Context::new(context_id, effective_app_id, Hash::default()); let context = entry.insert(ContextMeta { meta, @@ -236,6 +294,8 @@ impl Prepared<'_> { identity, identity_secret, sender_key, + group_id, + alias, }) } } @@ -254,6 +314,8 @@ async fn create_context( sender_key: PrivateKey, init_params: Vec, guard: OwnedMutexGuard, + group_id: Option, + alias: Option, ) -> eyre::Result { let storage = ContextStorage::from(datastore.clone(), context.id); // Create private storage (node-local, NOT synchronized) @@ -356,6 +418,12 @@ async fn create_context( // Height-based delta tracking removed - now using DAG-based approach + // Capture config strings before they are consumed by into_owned() below, + // so we can pass them to group_client for on-chain registration if needed. + let group_protocol = external_config.protocol.to_string(); + let group_network_id = external_config.network_id.to_string(); + let group_contract_id = external_config.contract_id.to_string(); + let mut handle = datastore.handle(); handle.put( @@ -401,7 +469,79 @@ async fn create_context( }, )?; + drop(handle); + + // Register context in group BEFORE subscribing so that a registration + // failure does not leave a subscribed-but-unregistered context. + // Note: membership was verified in Prepared::new(); a TOCTOU gap exists + // because the async create_context future may interleave with other actor + // messages (e.g. RemoveGroupMembers), but the window is small and the + // worst case is a single context associated with a since-removed member. + if let Some(ref gid) = group_id { + // Call contract to register context in the group on-chain. + // Derive signing key from the creator's identity_secret. + let signing_key_bytes: [u8; 32] = *identity_secret; + let mut group_client = context_client.group_client( + *gid, + signing_key_bytes, + group_protocol.clone(), + group_network_id.clone(), + group_contract_id.clone(), + ); + group_client + .register_context_in_group(context.id, None) + .await?; + + group_store::register_context_in_group(&datastore, gid, &context.id)?; + + // Write-through: store initial visibility locally so queries work + // without waiting for a sync. Matches on-chain behavior where + // register_context_in_group creates visibility with group defaults. + if let Ok(Some(default_vis_mode)) = group_store::get_default_visibility(&datastore, gid) { + let creator_bytes: [u8; 32] = *identity; + let _ = group_store::set_context_visibility( + &datastore, + gid, + &context.id, + default_vis_mode, + creator_bytes, + ); + // Auto-add creator to allowlist for Restricted contexts + if default_vis_mode == 1u8 { + let _ = group_store::add_to_context_allowlist( + &datastore, + gid, + &context.id, + &PublicKey::from(creator_bytes), + ); + } + } + } + node_client.subscribe(&context.id).await?; + if let Some(ref gid) = group_id { + let _ = node_client + .broadcast_group_mutation( + gid.to_bytes(), + calimero_node_primitives::sync::GroupMutationKind::ContextAttached, + ) + .await; + + if let Some(ref alias_str) = alias { + let _ = group_store::set_context_alias(&datastore, gid, &context.id, alias_str); + + let _ = node_client + .broadcast_group_mutation( + gid.to_bytes(), + calimero_node_primitives::sync::GroupMutationKind::ContextAliasSet { + context_id: *context.id, + alias: alias_str.clone(), + }, + ) + .await; + } + } + Ok(context.root_hash) } diff --git a/crates/context/src/handlers/create_group.rs b/crates/context/src/handlers/create_group.rs new file mode 100644 index 0000000000..68c9633d45 --- /dev/null +++ b/crates/context/src/handlers/create_group.rs @@ -0,0 +1,162 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_config::types as config_types; +use calimero_context_config::types::AppKey; +use calimero_context_primitives::group::{CreateGroupRequest, CreateGroupResponse}; +use calimero_primitives::context::GroupMemberRole; +use calimero_store::key::GroupMetaValue; +use calimero_store::Store; +use rand::Rng; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + CreateGroupRequest { + group_id, + app_key, + application_id, + upgrade_policy, + alias, + }: CreateGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve admin_identity from node group identity + let admin_identity = match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "admin_identity not provided and node has no configured group identity" + ))) + } + }; + + // Resolve app_key: use provided value or generate random + let app_key = app_key.unwrap_or_else(|| { + let bytes: [u8; 32] = rand::thread_rng().gen(); + AppKey::from(bytes) + }); + + // Resolve signing_key: node identity key or stored key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // Sync validation + let group_id = group_id.unwrap_or_else(|| { + let bytes: [u8; 32] = rand::thread_rng().gen(); + bytes.into() + }); + + if let Ok(Some(_)) = group_store::load_group_meta(&self.datastore, &group_id) { + return ActorResponse::reply(Err(eyre::eyre!("group '{group_id:?}' already exists"))); + } + + // Load application meta to build contract Application type + let app_meta = match load_app_meta(&self.datastore, &application_id) { + Ok(meta) => meta, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let datastore = self.datastore.clone(); + let node_client = self.node_client.clone(); + + // Auto-store signing key for future use (group is about to be created with + // admin_identity as the first admin, so store it keyed to that identity) + if let Some(ref sk) = signing_key { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &admin_identity, + sk, + ); + } + + // Build group_client if signing_key available, falling back to stored key + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &admin_identity) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + // Call contract if signing_key was provided + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + + let contract_app = build_contract_application(&application_id, &app_meta)?; + + group_client.create_group(app_key, contract_app).await?; + } + + // Local cache write + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let meta = GroupMetaValue { + app_key: app_key.to_bytes(), + target_application_id: application_id, + upgrade_policy, + created_at: now, + admin_identity: admin_identity.into(), + migration: None, + }; + + group_store::save_group_meta(&datastore, &group_id, &meta)?; + group_store::add_group_member( + &datastore, + &group_id, + &admin_identity, + GroupMemberRole::Admin, + )?; + + if let Some(ref alias_str) = alias { + group_store::set_group_alias(&datastore, &group_id, alias_str)?; + } + + let _ = node_client.subscribe_group(group_id.to_bytes()).await; + + info!(?group_id, %admin_identity, "group created"); + + Ok(CreateGroupResponse { group_id }) + } + .into_actor(self), + ) + } +} + +fn load_app_meta( + datastore: &Store, + application_id: &calimero_primitives::application::ApplicationId, +) -> eyre::Result { + let handle = datastore.handle(); + let key = calimero_store::key::ApplicationMeta::new(*application_id); + handle + .get(&key)? + .ok_or_else(|| eyre::eyre!("application '{application_id}' not found")) +} + +pub(crate) fn build_contract_application( + application_id: &calimero_primitives::application::ApplicationId, + app_meta: &calimero_store::types::ApplicationMeta, +) -> eyre::Result> { + Ok(config_types::Application::new( + application_id.rt()?, + app_meta.bytecode.blob_id().rt()?, + app_meta.size, + config_types::ApplicationSource(app_meta.source.to_string().into()), + config_types::ApplicationMetadata(calimero_context_config::repr::Repr::new( + app_meta.metadata.to_vec().into(), + )), + )) +} diff --git a/crates/context/src/handlers/create_group_invitation.rs b/crates/context/src/handlers/create_group_invitation.rs new file mode 100644 index 0000000000..7a18080def --- /dev/null +++ b/crates/context/src/handlers/create_group_invitation.rs @@ -0,0 +1,123 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_config::types::{ + GroupInvitationFromAdmin, SignedGroupOpenInvitation, SignerId, +}; +use calimero_context_config::MemberCapabilities; +use calimero_context_primitives::group::{ + CreateGroupInvitationRequest, CreateGroupInvitationResponse, +}; +use calimero_primitives::identity::PrivateKey; +use rand::Rng; +use sha2::{Digest, Sha256}; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + CreateGroupInvitationRequest { + group_id, + requester, + expiration_block_height, + }: CreateGroupInvitationRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Auto-store node signing key ONLY when the requester IS the node's own identity + if let Some((node_pk, node_sk)) = node_identity { + if requester == node_pk { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &requester, + &node_sk, + ); + } + } + + let result = (|| { + // 1. Group must exist + let _meta = group_store::load_group_meta(&self.datastore, &group_id)? + .ok_or_else(|| eyre::eyre!("group not found"))?; + + // 2. Requester must be admin or hold CAN_INVITE_MEMBERS capability + group_store::require_group_admin_or_capability( + &self.datastore, + &group_id, + &requester, + MemberCapabilities::CAN_INVITE_MEMBERS, + "create group invitation", + )?; + + // 3. Verify node holds the requester's signing key + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + + // 4. Extract contract coordinates + let params = self + .external_config + .params + .get("near") + .ok_or_else(|| eyre::eyre!("no 'near' protocol config"))?; + + // 6. Fetch admin signing key and construct + sign the invitation + let signing_key_bytes = + group_store::get_group_signing_key(&self.datastore, &group_id, &requester)? + .ok_or_else(|| eyre::eyre!("signing key not found for requester"))?; + let private_key = PrivateKey::from(signing_key_bytes); + + let mut rng = rand::thread_rng(); + let secret_salt: [u8; 32] = rng.gen(); + + let expiration_block_height: u64 = expiration_block_height.unwrap_or(999_999_999); + + let inviter_signer_id = SignerId::from(*requester); + + let invitation = GroupInvitationFromAdmin { + inviter_identity: inviter_signer_id, + group_id, + expiration_height: expiration_block_height, + secret_salt, + protocol: "near".to_string(), + network: params.network.clone(), + contract_id: params.contract_id.clone(), + }; + + // Sign: borsh-serialize → SHA256 → ed25519_sign + let invitation_bytes = borsh::to_vec(&invitation) + .map_err(|e| eyre::eyre!("failed to serialize invitation: {e}"))?; + let hash = Sha256::digest(&invitation_bytes); + let signature = private_key + .sign(&hash) + .map_err(|e| eyre::eyre!("signing failed: {e}"))?; + let inviter_signature = hex::encode(signature.to_bytes()); + + let group_alias = group_store::get_group_alias(&self.datastore, &group_id)?; + + Ok(CreateGroupInvitationResponse { + invitation: SignedGroupOpenInvitation { + invitation, + inviter_signature, + }, + group_alias, + }) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/delete_context.rs b/crates/context/src/handlers/delete_context.rs index 2af5d619ee..844468d9ca 100644 --- a/crates/context/src/handlers/delete_context.rs +++ b/crates/context/src/handlers/delete_context.rs @@ -4,20 +4,24 @@ use actix::{ActorResponse, ActorTryFutureExt, Handler, Message, WrapFuture}; use calimero_context_primitives::messages::{DeleteContextRequest, DeleteContextResponse}; use calimero_node_primitives::client::NodeClient; use calimero_primitives::context::ContextId; +use calimero_primitives::identity::PublicKey; use calimero_store::key::Key; use calimero_store::layer::{ReadLayer, WriteLayer}; use calimero_store::{key, Store}; use either::Either; use eyre::bail; -use crate::ContextManager; +use crate::{group_store, ContextManager}; impl Handler for ContextManager { type Result = ActorResponse::Result>; fn handle( &mut self, - DeleteContextRequest { context_id }: DeleteContextRequest, + DeleteContextRequest { + context_id, + requester, + }: DeleteContextRequest, _ctx: &mut Self::Context, ) -> Self::Result { let context = self.contexts.get(&context_id); @@ -38,6 +42,36 @@ impl Handler for ContextManager { let datastore = self.datastore.clone(); let node_client = self.node_client.clone(); + let context_client = self.context_client.clone(); + let near_params = self.external_config.params.get("near").map(|params| { + ( + "near".to_owned(), + params.network.clone(), + params.contract_id.clone(), + ) + }); + + let group_id_for_context = + match group_store::get_group_for_context(&self.datastore, &context_id) { + Ok(g) => g, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + if let Some(group_id) = group_id_for_context { + let requester = match requester { + Some(r) => r, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester required to delete a group context" + ))) + } + }; + if let Err(err) = + group_store::require_group_admin(&self.datastore, &group_id, &requester) + { + return ActorResponse::reply(Err(err)); + } + } let task = async move { let _guard = match guard { @@ -46,7 +80,15 @@ impl Handler for ContextManager { None => None, }; - delete_context(datastore, node_client, context_id).await?; + delete_context( + datastore, + node_client, + context_client, + context_id, + requester, + near_params, + ) + .await?; Ok(DeleteContextResponse { deleted: true }) }; @@ -62,7 +104,10 @@ impl Handler for ContextManager { async fn delete_context( datastore: Store, node_client: NodeClient, + context_client: calimero_context_primitives::client::ContextClient, context_id: ContextId, + requester: Option, + near_params: Option<(String, String, String)>, ) -> eyre::Result<()> { node_client.unsubscribe(&context_id).await?; @@ -89,6 +134,33 @@ async fn delete_context( // Context "deletion" should be a soft delete (marking as inactive/left) // rather than actually removing DAG history. See issue for details. + if let Some(group_id) = group_store::get_group_for_context(&datastore, &context_id)? { + if let Some((protocol, network_id, contract_id)) = near_params { + let sk = requester.and_then(|r| { + group_store::get_group_signing_key(&datastore, &group_id, &r) + .ok() + .flatten() + }); + + if let Some(sk) = sk { + let mut group_client = + context_client.group_client(group_id, sk, protocol, network_id, contract_id); + group_client + .unregister_context_from_group(context_id) + .await?; + } + } + + group_store::unregister_context_from_group(&datastore, &group_id, &context_id)?; + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + calimero_node_primitives::sync::GroupMutationKind::ContextDetached, + ) + .await; + } + Ok(()) } diff --git a/crates/context/src/handlers/delete_group.rs b/crates/context/src/handlers/delete_group.rs new file mode 100644 index 0000000000..4f8608f293 --- /dev/null +++ b/crates/context/src/handlers/delete_group.rs @@ -0,0 +1,112 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::{DeleteGroupRequest, DeleteGroupResponse}; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + DeleteGroupRequest { + group_id, + requester, + }: DeleteGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // Sync validation + if let Err(err) = (|| -> eyre::Result<()> { + let Some(_meta) = group_store::load_group_meta(&self.datastore, &group_id)? else { + bail!("group '{group_id:?}' not found"); + }; + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + let ctx_count = group_store::count_group_contexts(&self.datastore, &group_id)?; + if ctx_count > 0 { + bail!( + "cannot delete group '{group_id:?}': still has {ctx_count} associated context(s)" + ); + } + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + // Auto-store signing key for future use + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let datastore = self.datastore.clone(); + let node_client = self.node_client.clone(); + let group_id_bytes = group_id.to_bytes(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + group_client.delete_group().await?; + } + + // Remove all members in bounded batches to cap peak allocation + loop { + let batch = group_store::list_group_members(&datastore, &group_id, 0, 500)?; + if batch.is_empty() { + break; + } + for (identity, _role) in &batch { + group_store::remove_group_member(&datastore, &group_id, identity)?; + } + } + + // Clean up any in-progress or completed upgrade record so crash + // recovery does not find orphaned entries for deleted groups. + group_store::delete_group_upgrade(&datastore, &group_id)?; + group_store::delete_all_group_signing_keys(&datastore, &group_id)?; + group_store::delete_group_meta(&datastore, &group_id)?; + + let _ = node_client + .broadcast_group_mutation(group_id_bytes, GroupMutationKind::Deleted) + .await; + let _ = node_client.unsubscribe_group(group_id_bytes).await; + + info!(?group_id, %requester, "group deleted"); + + Ok(DeleteGroupResponse { deleted: true }) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/detach_context_from_group.rs b/crates/context/src/handlers/detach_context_from_group.rs new file mode 100644 index 0000000000..92e6798457 --- /dev/null +++ b/crates/context/src/handlers/detach_context_from_group.rs @@ -0,0 +1,115 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::DetachContextFromGroupRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::warn; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + DetachContextFromGroupRequest { + group_id, + context_id, + requester, + }: DetachContextFromGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + let current_group = group_store::get_group_for_context(&self.datastore, &context_id)?; + if current_group.as_ref() != Some(&group_id) { + bail!("context '{context_id}' does not belong to group '{group_id:?}'"); + } + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + // Auto-store signing key for future use + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let datastore = self.datastore.clone(); + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + group_client + .unregister_context_from_group(context_id) + .await?; + } + + group_store::unregister_context_from_group(&datastore, &group_id, &context_id)?; + + // Clean up orphaned visibility and allowlist data + if let Err(err) = + group_store::delete_context_visibility(&datastore, &group_id, &context_id) + { + warn!( + ?group_id, %context_id, %err, + "failed to clean up context visibility on detach" + ); + } + if let Err(err) = + group_store::clear_context_allowlist(&datastore, &group_id, &context_id) + { + warn!( + ?group_id, %context_id, %err, + "failed to clean up context allowlist on detach" + ); + } + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::ContextDetached, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/execute.rs b/crates/context/src/handlers/execute.rs index a1851dc94e..3b12f79c00 100644 --- a/crates/context/src/handlers/execute.rs +++ b/crates/context/src/handlers/execute.rs @@ -10,13 +10,13 @@ use calimero_context_config::repr::ReprTransmute; use calimero_context_primitives::client::crypto::ContextIdentity; use calimero_context_primitives::client::ContextClient; use calimero_context_primitives::messages::{ - ExecuteError, ExecuteEvent, ExecuteRequest, ExecuteResponse, + ExecuteError, ExecuteEvent, ExecuteRequest, ExecuteResponse, MigrationParams, }; use calimero_context_primitives::{ContextAtomic, ContextAtomicKey}; use calimero_node_primitives::client::NodeClient; use calimero_primitives::alias::Alias; use calimero_primitives::application::ApplicationId; -use calimero_primitives::context::{Context, ContextId}; +use calimero_primitives::context::{Context, ContextId, UpgradePolicy}; use calimero_primitives::events::{ ContextEvent, ContextEventPayload, ExecutionEvent, NodeEvent, StateMutationPayload, }; @@ -39,6 +39,9 @@ use tokio::sync::OwnedMutexGuard; use tracing::{debug, error, info, warn}; use crate::error::ContextError; +use crate::handlers::update_application::{ + update_application_id, update_application_with_migration, +}; use crate::handlers::utils::{process_context_mutations, StoreContextHost}; use crate::metrics::ExecutionLabels; use crate::ContextManager; @@ -91,6 +94,8 @@ impl Handler for ContextManager { } }; + let current_application_id = context.meta.application_id; + let is_state_op = "__calimero_sync_next" == method; if !is_state_op && *context.meta.root_hash == [0; 32] { @@ -103,6 +108,18 @@ impl Handler for ContextManager { Some(ContextAtomic::Held(ContextAtomicKey(guard))) => (Either::Left(guard), true), }; + // Lazy upgrade: if context belongs to a LazyOnAccess group and is stale, + // trigger an upgrade before executing the method. + // Note: placed after context.lock() so that `context` borrow is released + // before we access self.datastore. + // Skip for sync operations — the state payload was produced by the old app + // version and must be applied as-is, not against a newly upgraded WASM. + let lazy_upgrade_params = if is_state_op { + None + } else { + maybe_lazy_upgrade(&self.datastore, &context_id, ¤t_application_id) + }; + let external_config = match self.context_client.context_config(&context_id) { Ok(Some(external_config)) => external_config, Ok(None) => { @@ -176,14 +193,166 @@ impl Handler for ContextManager { } .into_actor(self); - let context_task = guard_task.map(move |guard, act, _ctx| { - let Some(context) = act.get_or_fetch_context(&context_id)? else { - bail!(ContextError::ContextDeleted { context_id }); - }; + // Extract actor-owned values for the lazy upgrade path synchronously so the + // context_task future can call update_application_id / update_application_with_migration + // directly without routing through the actor mailbox (which would deadlock while an + // ActorFuture is in flight on the same actor). + let lazy_upgrade_task = guard_task.map(move |guard, act, _ctx| { + if let Some((target_app_id, migrate_method, group_id)) = lazy_upgrade_params { + info!( + %context_id, + %target_app_id, + %executor, + "performing lazy upgrade before execution" + ); + let datastore = act.datastore.clone(); + let node_client = act.node_client.clone(); + let context_client = act.context_client.clone(); + let context_meta = act.contexts.get(&context_id).map(|c| c.meta.clone()); + let application = act.applications.get(&target_app_id).cloned(); + return Ok(Either::Right(( + guard, + datastore, + node_client, + context_client, + context_id, + target_app_id, + context_meta, + application, + migrate_method, + group_id, + ))); + } + Ok(Either::Left(guard)) + }); - Ok((guard, context.meta.clone())) + let context_task = lazy_upgrade_task.and_then(move |either, act, _ctx| { + match either { + Either::Left(guard) => async move { Ok(guard) }.into_actor(act).boxed_local(), + Either::Right(( + guard, + datastore, + node_client, + context_client, + cid, + target_app, + context_meta, + application, + migrate, + group_id, + )) => { + if let Some(method) = migrate { + let migration_params = MigrationParams { method: method.clone() }; + // Migration: load the WASM module via get_module (actor-aware cache), + // then call update_application_with_migration directly — no mailbox. + act.get_module(target_app) + .then(move |module_result, act, _ctx| { + // Re-read cached values; they may have been refreshed during load + let context_meta = + act.contexts.get(&cid).map(|c| c.meta.clone()); + let application = act.applications.get(&target_app).cloned(); + async move { + match module_result { + Ok(module) => { + match update_application_with_migration( + datastore.clone(), + node_client, + context_client, + cid, + context_meta, + target_app, + application, + executor, + Some(migration_params), + module, + ) + .await + { + Ok(_) => { + // Record that this migration was applied so + // maybe_lazy_upgrade skips it on future accesses. + if let Err(err) = + crate::group_store::set_context_last_migration( + &datastore, + &group_id, + &cid, + &method, + ) + { + warn!( + %cid, + %err, + "failed to record migration marker" + ); + } + } + Err(err) => { + warn!( + %cid, + %target_app, + %err, + "lazy upgrade (migration) failed, proceeding with current application" + ); + } + } + } + Err(err) => { + warn!( + %cid, + %target_app, + %err, + "failed to load module for lazy upgrade migration" + ); + } + } + Ok(guard) + } + .into_actor(act) + }) + .boxed_local() + } else { + // No migration: call update_application_id directly — no mailbox. + async move { + if let Err(err) = update_application_id( + datastore, + node_client, + context_client, + cid, + context_meta, + target_app, + application, + executor, + ) + .await + { + warn!( + %cid, + %target_app, + %err, + "lazy upgrade failed, proceeding with current application" + ); + } + Ok(guard) + } + .into_actor(act) + .boxed_local() + } + } + } }); + // Re-fetch context after possible lazy upgrade (application_id may have changed) + let context_task = context_task.map( + move |guard_result: eyre::Result>, act, _ctx| { + let guard = guard_result?; + let Some(context) = act.get_or_fetch_context(&context_id)? else { + bail!(ContextError::ContextDeleted { context_id }); + }; + + Ok((guard, context.meta.clone())) + }, + ); + let module_task = context_task.and_then(move |(guard, context), act, _ctx| { act.get_module(context.application_id) .map_ok(move |module, _act, _ctx| (guard, context, module)) @@ -1128,3 +1297,84 @@ fn sign_user_actions( } Ok(()) } + +/// Checks if a context belongs to a group with LazyOnAccess policy and +/// needs an upgrade or migration. +/// +/// Returns `(target_application_id, migrate_method, group_id)` when an +/// upgrade should be performed. The `group_id` is included so the caller +/// can record a per-context migration marker after a successful run. +fn maybe_lazy_upgrade( + datastore: &Store, + context_id: &ContextId, + current_application_id: &ApplicationId, +) -> Option<( + ApplicationId, + Option, + calimero_context_config::types::ContextGroupId, +)> { + use crate::group_store; + + // 1. Check if context belongs to a group + let group_id = match group_store::get_group_for_context(datastore, context_id) { + Ok(Some(gid)) => gid, + Ok(None) => return None, // not in a group + Err(err) => { + debug!(%err, %context_id, "failed to check group for context during lazy upgrade"); + return None; + } + }; + + // 2. Load group metadata + let meta = match group_store::load_group_meta(datastore, &group_id) { + Ok(Some(m)) => m, + Ok(None) => return None, // group deleted? + Err(err) => { + debug!(%err, ?group_id, "failed to load group meta during lazy upgrade"); + return None; + } + }; + + // 3. Check policy is LazyOnAccess + if !matches!(meta.upgrade_policy, UpgradePolicy::LazyOnAccess) { + return None; + } + + // 4. Extract migration method from group meta (set during upgrade) + let migrate_method = meta + .migration + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()); + + // 5. Compare current vs target application + if *current_application_id == meta.target_application_id { + // IDs match — only proceed if there is a pending migration that + // hasn't been applied to this context yet. + let Some(ref method) = migrate_method else { + return None; // no migration, context is already up to date + }; + + // Check per-context marker set after a successful migration run. + let already_applied = + group_store::get_context_last_migration(datastore, &group_id, context_id) + .ok() + .flatten() + .map(|last| last == *method) + .unwrap_or(false); + + if already_applied { + return None; // migration was already applied to this context + } + // Fall through: migration is pending. + } + + info!( + %context_id, + ?group_id, + %current_application_id, + target_app=%meta.target_application_id, + "lazy upgrade triggered for context" + ); + + Some((meta.target_application_id, migrate_method, group_id)) +} diff --git a/crates/context/src/handlers/get_context_allowlist.rs b/crates/context/src/handlers/get_context_allowlist.rs new file mode 100644 index 0000000000..7330a8818c --- /dev/null +++ b/crates/context/src/handlers/get_context_allowlist.rs @@ -0,0 +1,33 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::GetContextAllowlistRequest; +use calimero_primitives::identity::PublicKey; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetContextAllowlistRequest { + group_id, + context_id, + }: GetContextAllowlistRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| -> eyre::Result> { + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + + group_store::list_context_allowlist(&self.datastore, &group_id, &context_id) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/get_context_visibility.rs b/crates/context/src/handlers/get_context_visibility.rs new file mode 100644 index 0000000000..fb4d3ecccc --- /dev/null +++ b/crates/context/src/handlers/get_context_visibility.rs @@ -0,0 +1,48 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::{ + GetContextVisibilityRequest, GetContextVisibilityResponse, +}; +use calimero_primitives::identity::PublicKey; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetContextVisibilityRequest { + group_id, + context_id, + }: GetContextVisibilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| -> eyre::Result { + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + + let (mode_u8, creator_bytes) = + group_store::get_context_visibility(&self.datastore, &group_id, &context_id)? + .ok_or_else(|| eyre::eyre!("context visibility not found"))?; + + let mode = match mode_u8 { + 0 => calimero_context_config::VisibilityMode::Open, + 1 => calimero_context_config::VisibilityMode::Restricted, + _ => bail!("invalid visibility mode value: {mode_u8}"), + }; + + Ok(GetContextVisibilityResponse { + mode, + creator: PublicKey::from(creator_bytes), + }) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/get_group_for_context.rs b/crates/context/src/handlers/get_group_for_context.rs new file mode 100644 index 0000000000..00b413b68b --- /dev/null +++ b/crates/context/src/handlers/get_group_for_context.rs @@ -0,0 +1,19 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::GetGroupForContextRequest; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetGroupForContextRequest { context_id }: GetGroupForContextRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = group_store::get_group_for_context(&self.datastore, &context_id); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/get_group_info.rs b/crates/context/src/handlers/get_group_info.rs new file mode 100644 index 0000000000..0b9dde9321 --- /dev/null +++ b/crates/context/src/handlers/get_group_info.rs @@ -0,0 +1,64 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::{GetGroupInfoRequest, GroupInfoResponse}; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetGroupInfoRequest { group_id }: GetGroupInfoRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| { + let Some(meta) = group_store::load_group_meta(&self.datastore, &group_id)? else { + bail!("group '{group_id:?}' not found"); + }; + + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + + let member_count = group_store::count_group_members(&self.datastore, &group_id)? as u64; + + let context_count = + group_store::count_group_contexts(&self.datastore, &group_id)? as u64; + + let active_upgrade = + group_store::load_group_upgrade(&self.datastore, &group_id)?.map(Into::into); + + let default_capabilities = + group_store::get_default_capabilities(&self.datastore, &group_id)?.unwrap_or(0); + + let default_visibility = + match group_store::get_default_visibility(&self.datastore, &group_id)?.unwrap_or(0) + { + 1 => "restricted".to_owned(), + _ => "open".to_owned(), + }; + + let alias = group_store::get_group_alias(&self.datastore, &group_id)?; + + Ok(GroupInfoResponse { + group_id, + app_key: meta.app_key.into(), + target_application_id: meta.target_application_id, + upgrade_policy: meta.upgrade_policy, + member_count, + context_count, + active_upgrade, + default_capabilities, + default_visibility, + alias, + }) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/get_group_upgrade_status.rs b/crates/context/src/handlers/get_group_upgrade_status.rs new file mode 100644 index 0000000000..752ad5ce94 --- /dev/null +++ b/crates/context/src/handlers/get_group_upgrade_status.rs @@ -0,0 +1,27 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::GetGroupUpgradeStatusRequest; +use eyre::bail; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetGroupUpgradeStatusRequest { group_id }: GetGroupUpgradeStatusRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| { + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + group_store::load_group_upgrade(&self.datastore, &group_id) + .map(|opt| opt.map(Into::into)) + })(); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/get_member_capabilities.rs b/crates/context/src/handlers/get_member_capabilities.rs new file mode 100644 index 0000000000..d8b046a081 --- /dev/null +++ b/crates/context/src/handlers/get_member_capabilities.rs @@ -0,0 +1,36 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::{ + GetMemberCapabilitiesRequest, GetMemberCapabilitiesResponse, +}; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + GetMemberCapabilitiesRequest { group_id, member }: GetMemberCapabilitiesRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| -> eyre::Result { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + if group_store::get_group_member_role(&self.datastore, &group_id, &member)?.is_none() { + bail!("identity is not a member of group '{group_id:?}'"); + } + + let capabilities = + group_store::get_member_capability(&self.datastore, &group_id, &member)? + .unwrap_or(0); + + Ok(GetMemberCapabilitiesResponse { capabilities }) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/join_group.rs b/crates/context/src/handlers/join_group.rs new file mode 100644 index 0000000000..77bcf0b2d7 --- /dev/null +++ b/crates/context/src/handlers/join_group.rs @@ -0,0 +1,197 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::types::{GroupRevealPayloadData, SignedGroupRevealPayload, SignerId}; +use calimero_context_primitives::group::{JoinGroupRequest, JoinGroupResponse}; +use calimero_node_primitives::sync::GroupMutationKind; +use calimero_primitives::context::GroupMemberRole; +use calimero_primitives::identity::{PrivateKey, PublicKey}; +use eyre::bail; +use sha2::{Digest, Sha256}; +use tracing::info; +use tracing::warn; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + JoinGroupRequest { + invitation, + group_alias, + }: JoinGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve joiner_identity from node group identity + let joiner_identity = match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "joiner_identity not provided and node has no configured group identity" + ))) + } + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // Extract fields directly from the SignedGroupOpenInvitation + let inv = &invitation.invitation; + let group_id = inv.group_id; + let protocol = inv.protocol.clone(); + let network_id = inv.network.clone(); + let contract_id = inv.contract_id.clone(); + let expiration_block_height = inv.expiration_height; + + // Derive inviter_identity (PublicKey) for local admin validation + let inviter_identity = PublicKey::from(inv.inviter_identity.to_bytes()); + + // Check if we need to bootstrap from chain + let needs_chain_sync = group_store::load_group_meta(&self.datastore, &group_id) + .map(|opt| opt.is_none()) + .unwrap_or(true); + + // Auto-store signing key if provided + if let Some(ref sk) = signing_key { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &joiner_identity, + sk, + ); + } + + // Resolve effective signing key (provided, node key, or previously stored) + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &joiner_identity) + .ok() + .flatten() + }); + + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + let datastore = self.datastore.clone(); + let context_client = self.context_client.clone(); + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + // Phase 1: Bootstrap from chain if local state is missing + if needs_chain_sync { + let (mut meta, _group_info) = group_store::sync_group_state_from_contract( + &datastore, + &context_client, + &group_id, + &protocol, + &network_id, + &contract_id, + ) + .await?; + + // Set admin_identity to inviter (who created the invitation) + meta.admin_identity = inviter_identity; + group_store::save_group_meta(&datastore, &group_id, &meta)?; + // sync_group_state_from_contract already reflects on-chain member state; + // do NOT re-insert the inviter as Admin here — that would make the + // is_group_admin check below trivially pass even for demoted admins. + } + + // Phase 2: Validate + if !group_store::is_group_admin(&datastore, &group_id, &inviter_identity)? { + bail!("inviter is no longer an admin of this group"); + } + + if group_store::check_group_membership(&datastore, &group_id, &joiner_identity)? { + bail!("identity is already a member of this group"); + } + + // Phase 3: Contract commit/reveal + local store + if let Some(client_result) = group_client_result { + let group_client = client_result?; + + let new_member_signer_id = SignerId::from(*joiner_identity); + + // Build the reveal payload data using the invitation directly + let reveal_payload_data = GroupRevealPayloadData { + signed_open_invitation: invitation, + new_member_identity: new_member_signer_id, + }; + + // Compute commitment hash + let reveal_data_bytes = borsh::to_vec(&reveal_payload_data)?; + let commitment_hash = hex::encode(Sha256::digest(&reveal_data_bytes)); + + // Step 1: Commit + group_client + .commit_group_invitation(commitment_hash, expiration_block_height) + .await?; + + // Step 2: Sign the reveal payload data with the joiner's key + let effective_key = effective_signing_key + .ok_or_else(|| eyre::eyre!("signing key required for commit/reveal"))?; + let joiner_private_key = PrivateKey::from(effective_key); + let hash = Sha256::digest(&reveal_data_bytes); + let signature = joiner_private_key + .sign(&hash) + .map_err(|e| eyre::eyre!("signing reveal payload failed: {e}"))?; + let invitee_signature = hex::encode(signature.to_bytes()); + + let signed_payload = SignedGroupRevealPayload { + data: reveal_payload_data, + invitee_signature, + }; + + // Step 3: Reveal + group_client.reveal_group_invitation(signed_payload).await?; + } + + group_store::add_group_member( + &datastore, + &group_id, + &joiner_identity, + GroupMemberRole::Member, + )?; + + if let Some(ref alias_str) = group_alias { + group_store::set_group_alias(&datastore, &group_id, alias_str)?; + } + + let _ = node_client.subscribe_group(group_id.to_bytes()).await; + let _ = node_client + .broadcast_group_mutation(group_id.to_bytes(), GroupMutationKind::MembersAdded) + .await; + + // Always sync group state from contract after joining to ensure + // contexts registered before this node joined are immediately visible. + // This runs after Phase 3 (on-chain join) so Node B is an authorized member. + if let Err(err) = group_store::sync_group_state_from_contract( + &datastore, + &context_client, + &group_id, + &protocol, + &network_id, + &contract_id, + ) + .await + { + warn!(?err, "Failed to sync group state from contract after join"); + } + + info!( + ?group_id, + %joiner_identity, + "new member joined group via invitation" + ); + + Ok(JoinGroupResponse { + group_id, + member_identity: joiner_identity, + }) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/join_group_context.rs b/crates/context/src/handlers/join_group_context.rs new file mode 100644 index 0000000000..03e84a971e --- /dev/null +++ b/crates/context/src/handlers/join_group_context.rs @@ -0,0 +1,183 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_primitives::client::crypto::ContextIdentity; +use calimero_context_primitives::group::{JoinGroupContextRequest, JoinGroupContextResponse}; +use calimero_primitives::context::ContextConfigParams; +use calimero_primitives::identity::PrivateKey; +use eyre::bail; +use tracing::info; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + JoinGroupContextRequest { + group_id, + context_id, + }: JoinGroupContextRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + // Resolve joiner identity from node group identity. + let (joiner_identity, effective_signing_key) = match self.node_group_identity() { + Some((pk, sk)) => (pk, Some(sk)), + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "joiner_identity not provided and node has no configured group identity" + ))); + } + }; + + // Validate: group exists, joiner is a member, and has permission to join this context. + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group not found"); + } + if !group_store::check_group_membership(&self.datastore, &group_id, &joiner_identity)? { + bail!("identity is not a member of the group"); + } + match group_store::get_context_visibility(&self.datastore, &group_id, &context_id)? { + Some((0, _)) => { + // Open context: require admin or CAN_JOIN_OPEN_CONTEXTS. + if !group_store::is_group_admin_or_has_capability( + &self.datastore, + &group_id, + &joiner_identity, + calimero_context_config::MemberCapabilities::CAN_JOIN_OPEN_CONTEXTS, + )? { + bail!( + "identity lacks permission to join open context '{context_id:?}' \ + (not an admin and CAN_JOIN_OPEN_CONTEXTS is not set)" + ); + } + } + Some((1, _)) => { + // Restricted context: require admin or on allowlist. + let is_admin = + group_store::is_group_admin(&self.datastore, &group_id, &joiner_identity)?; + let on_allowlist = group_store::check_context_allowlist( + &self.datastore, + &group_id, + &context_id, + &joiner_identity, + )?; + if !is_admin && !on_allowlist { + bail!( + "identity is not permitted to join restricted context '{context_id:?}' \ + (not an admin and not on the context allowlist)" + ); + } + } + Some((mode, _)) => bail!("unknown context visibility mode: {mode}"), + None => { + // No visibility record synced yet; only admins may proceed. + if !group_store::is_group_admin(&self.datastore, &group_id, &joiner_identity)? { + bail!( + "context visibility not found for '{context_id:?}'; \ + only admins may join" + ); + } + } + } + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + let datastore = self.datastore.clone(); + let context_client = self.context_client.clone(); + let node_client = self.node_client.clone(); + + let protocol = "near".to_owned(); + let params = match self.external_config.params.get("near") { + Some(p) => p.clone(), + None => { + return ActorResponse::reply(Err(eyre::eyre!("no 'near' protocol config"))); + } + }; + + ActorResponse::r#async( + async move { + // Generate a context identity for this context. + let mut rng = rand::thread_rng(); + let identity_secret = PrivateKey::random(&mut rng); + let identity_pk = identity_secret.public_key(); + let sender_key = PrivateKey::random(&mut rng); + + let context_identity: calimero_context_config::types::ContextIdentity = + identity_pk.rt()?; + + // Call contract to add the new member to the context via group. + if let Some(client_result) = group_client_result { + let group_client = client_result?; + group_client + .join_context_via_group(context_id, context_identity) + .await?; + } + + // Register the context-group mapping locally so that + // maybe_lazy_upgrade can find the group for this context. + group_store::register_context_in_group(&datastore, &group_id, &context_id)?; + + // Ensure we have context config locally. + // If the context is unknown, build config from protocol params + // and fetch the proxy contract so sync_context_config can + // bootstrap the context from on-chain state. + let config = if !context_client.has_context(&context_id)? { + let mut external_config = ContextConfigParams { + protocol: protocol.clone().into(), + network_id: params.network.clone().into(), + contract_id: params.contract_id.clone().into(), + proxy_contract: "".into(), + application_revision: 0, + members_revision: 0, + }; + + let external_client = + context_client.external_client(&context_id, &external_config)?; + let proxy_contract = external_client.config().get_proxy_contract().await?; + external_config.proxy_contract = proxy_contract.into(); + + Some(external_config) + } else { + None + }; + + let _ignored = context_client + .sync_context_config(context_id, config) + .await?; + + // Store the context identity locally. + context_client.update_identity( + &context_id, + &ContextIdentity { + public_key: identity_pk, + private_key: Some(identity_secret), + sender_key: Some(sender_key), + }, + )?; + + // Subscribe to context and trigger sync. + node_client.subscribe(&context_id).await?; + node_client.sync(Some(&context_id), None).await?; + + info!( + ?group_id, + ?context_id, + %identity_pk, + "joined context via group membership" + ); + + Ok(JoinGroupContextResponse { + context_id, + member_public_key: identity_pk, + }) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/list_all_groups.rs b/crates/context/src/handlers/list_all_groups.rs new file mode 100644 index 0000000000..6db92b3bc9 --- /dev/null +++ b/crates/context/src/handlers/list_all_groups.rs @@ -0,0 +1,46 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_config::types::ContextGroupId; +use calimero_context_primitives::group::{GroupSummary, ListAllGroupsRequest}; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + ListAllGroupsRequest { offset, limit }: ListAllGroupsRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| { + let Some((node_identity, _)) = self.node_group_identity() else { + return Ok(vec![]); + }; + + let entries = group_store::enumerate_all_groups(&self.datastore, offset, limit)?; + + let mut summaries = Vec::with_capacity(entries.len()); + for (group_id_bytes, meta) in entries { + let group_id = ContextGroupId::from(group_id_bytes); + if group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? + { + let alias = group_store::get_group_alias(&self.datastore, &group_id) + .ok() + .flatten(); + summaries.push(GroupSummary { + group_id, + app_key: meta.app_key.into(), + target_application_id: meta.target_application_id, + upgrade_policy: meta.upgrade_policy, + created_at: meta.created_at, + alias, + }); + } + } + Ok(summaries) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/list_group_contexts.rs b/crates/context/src/handlers/list_group_contexts.rs new file mode 100644 index 0000000000..79a6a8e66b --- /dev/null +++ b/crates/context/src/handlers/list_group_contexts.rs @@ -0,0 +1,42 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::{GroupContextEntry, ListGroupContextsRequest}; +use eyre::bail; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + ListGroupContextsRequest { + group_id, + offset, + limit, + }: ListGroupContextsRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| { + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + group_store::enumerate_group_contexts_with_aliases( + &self.datastore, + &group_id, + offset, + limit, + ) + .map(|entries| { + entries + .into_iter() + .map(|(context_id, alias)| GroupContextEntry { context_id, alias }) + .collect() + }) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/list_group_members.rs b/crates/context/src/handlers/list_group_members.rs new file mode 100644 index 0000000000..40bc7e5d90 --- /dev/null +++ b/crates/context/src/handlers/list_group_members.rs @@ -0,0 +1,50 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::{GroupMemberEntry, ListGroupMembersRequest}; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + ListGroupMembersRequest { + group_id, + offset, + limit, + }: ListGroupMembersRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| { + let Some((node_identity, _)) = self.node_group_identity() else { + bail!("node has no group identity configured"); + }; + if !group_store::check_group_membership(&self.datastore, &group_id, &node_identity)? { + bail!("node is not a member of group '{group_id:?}'"); + } + + let members = + group_store::list_group_members(&self.datastore, &group_id, offset, limit)?; + + let entries = members + .into_iter() + .map(|(identity, role)| { + let alias = + group_store::get_member_alias(&self.datastore, &group_id, &identity) + .ok() + .flatten(); + GroupMemberEntry { + identity, + role, + alias, + } + }) + .collect(); + Ok(entries) + })(); + + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/manage_context_allowlist.rs b/crates/context/src/handlers/manage_context_allowlist.rs new file mode 100644 index 0000000000..767ec77864 --- /dev/null +++ b/crates/context/src/handlers/manage_context_allowlist.rs @@ -0,0 +1,148 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_primitives::group::ManageContextAllowlistRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + ManageContextAllowlistRequest { + group_id, + context_id, + add, + remove, + requester, + }: ManageContextAllowlistRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + // Allowlist can be managed by admin or context creator + let is_admin = group_store::is_group_admin(&self.datastore, &group_id, &requester)?; + if !is_admin { + if let Some((_, creator_bytes)) = + group_store::get_context_visibility(&self.datastore, &group_id, &context_id)? + { + if creator_bytes != *requester { + bail!("only admin or context creator can manage allowlist"); + } + } else { + bail!("context visibility not found for context in group"); + } + } + + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + for member in &add { + group_store::add_to_context_allowlist( + &self.datastore, + &group_id, + &context_id, + member, + )?; + } + + for member in &remove { + group_store::remove_from_context_allowlist( + &self.datastore, + &group_id, + &context_id, + member, + )?; + } + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let current_allowlist = + match group_store::list_context_allowlist(&self.datastore, &group_id, &context_id) { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + let add_signer_ids: Vec = add + .iter() + .map(|pk| pk.rt()) + .collect::, _>>()?; + let remove_signer_ids: Vec = remove + .iter() + .map(|pk| pk.rt()) + .collect::, _>>()?; + group_client + .manage_context_allowlist(context_id, add_signer_ids, remove_signer_ids) + .await?; + } + + info!( + ?group_id, + %context_id, + added = add.len(), + removed = remove.len(), + "context allowlist updated" + ); + + let members_raw: Vec<[u8; 32]> = current_allowlist.iter().map(|pk| **pk).collect(); + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::ContextAllowlistSet { + context_id: *context_id, + members: members_raw, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/remove_group_members.rs b/crates/context/src/handlers/remove_group_members.rs new file mode 100644 index 0000000000..d8f122ab3e --- /dev/null +++ b/crates/context/src/handlers/remove_group_members.rs @@ -0,0 +1,137 @@ +use std::collections::BTreeSet; + +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_primitives::group::RemoveGroupMembersRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use calimero_primitives::context::GroupMemberRole; +use calimero_primitives::identity::PublicKey; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + RemoveGroupMembersRequest { + group_id, + members, + requester, + }: RemoveGroupMembersRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // Sync validation + if let Err(err) = (|| -> eyre::Result<()> { + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + let admin_count = group_store::count_group_admins(&self.datastore, &group_id)?; + let mut unique_admins_being_removed: BTreeSet = BTreeSet::new(); + for id in &members { + let role = group_store::get_group_member_role(&self.datastore, &group_id, id)?; + if role == Some(GroupMemberRole::Admin) { + unique_admins_being_removed.insert(*id); + } + } + + if admin_count <= unique_admins_being_removed.len() { + bail!("cannot remove all admins from group '{group_id:?}': at least one admin must remain"); + } + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + // Auto-store signing key for future use + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let self_identity = self.node_group_identity().map(|(pk, _)| pk); + let datastore = self.datastore.clone(); + let node_client = self.node_client.clone(); + let context_client = self.context_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + let signer_ids: Vec = members + .iter() + .map(|pk| pk.rt()) + .collect::, _>>()?; + group_client.remove_group_members(&signer_ids).await?; + } + + for identity in &members { + group_store::remove_group_member(&datastore, &group_id, identity)?; + } + + info!(?group_id, count = members.len(), %requester, "members removed from group"); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::MembersRemoved, + ) + .await; + + // Unsubscribe if this node's identity was removed + if let Some(self_pk) = self_identity { + if members.iter().any(|pk| *pk == self_pk) { + let _ = node_client.unsubscribe_group(group_id.to_bytes()).await; + } + } + + let contexts = + group_store::enumerate_group_contexts(&datastore, &group_id, 0, usize::MAX)?; + + for context_id in &contexts { + if let Err(err) = context_client.sync_context_config(*context_id, None).await { + tracing::warn!( + ?group_id, + %context_id, + ?err, + "failed to sync context after group member removal" + ); + } + } + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/retry_group_upgrade.rs b/crates/context/src/handlers/retry_group_upgrade.rs new file mode 100644 index 0000000000..9154efd49c --- /dev/null +++ b/crates/context/src/handlers/retry_group_upgrade.rs @@ -0,0 +1,127 @@ +use actix::{ActorFutureExt, ActorResponse, AsyncContext, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::{RetryGroupUpgradeRequest, UpgradeGroupResponse}; +use calimero_context_primitives::messages::MigrationParams; +use calimero_store::key::GroupUpgradeStatus; +use eyre::bail; +use tracing::info; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + RetryGroupUpgradeRequest { + group_id, + requester, + }: RetryGroupUpgradeRequest, + ctx: &mut Self::Context, + ) -> Self::Result { + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match self.node_group_identity() { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Validate + let result = (|| { + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + let upgrade = group_store::load_group_upgrade(&self.datastore, &group_id)? + .ok_or_else(|| eyre::eyre!("no upgrade found for this group"))?; + + match upgrade.status { + GroupUpgradeStatus::InProgress { failed, .. } if failed > 0 => {} + GroupUpgradeStatus::InProgress { .. } => { + bail!("upgrade is in progress with no failures — nothing to retry"); + } + GroupUpgradeStatus::Completed { .. } => { + bail!("upgrade is already completed"); + } + }; + + let meta = group_store::load_group_meta(&self.datastore, &group_id)? + .ok_or_else(|| eyre::eyre!("group not found"))?; + + let migration = upgrade + .migration + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) + .map(|method| MigrationParams { method }); + + // Use current context count rather than stored total which may be stale + let current_total = + group_store::count_group_contexts(&self.datastore, &group_id)? as u32; + + Ok((meta.target_application_id, migration, current_total)) + })(); + + let (target_application_id, migration, current_total) = match result { + Ok(v) => v, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + // Reject if a propagator is already running for this group (e.g. + // still in its automatic backoff sleep). Spawning a second one would + // cause conflicting status writes and double-counted completions. + if self.active_propagators.contains(&group_id) { + return ActorResponse::reply(Err(eyre::eyre!( + "a propagator is already running for this group; wait for it to finish" + ))); + } + + info!( + ?group_id, + %requester, + "retrying group upgrade for failed contexts" + ); + + // Persist reset status BEFORE spawning the propagator so that + // GET /upgrade/status immediately reflects the retry. + let status = GroupUpgradeStatus::InProgress { + total: current_total, + completed: 0, + failed: 0, + }; + + if let Err(err) = + super::upgrade_group::update_upgrade_status(&self.datastore, &group_id, status.clone()) + { + return ActorResponse::reply(Err(err)); + } + + // Re-spawn propagator (it will re-attempt all contexts; already-upgraded + // ones should be handled gracefully by update_application) + let context_client = self.context_client.clone(); + let datastore = self.datastore.clone(); + + self.active_propagators.insert(group_id); + + let propagator = super::upgrade_group::propagate_upgrade( + context_client, + datastore, + group_id, + target_application_id, + migration, + None, // no context to skip on retry + 0, // retry: no canary assumption + ); + + ctx.spawn(propagator.into_actor(self).map(move |_, act, _| { + act.active_propagators.remove(&group_id); + })); + + ActorResponse::reply(Ok(UpgradeGroupResponse { + group_id, + status: status.into(), + })) + } +} diff --git a/crates/context/src/handlers/set_context_visibility.rs b/crates/context/src/handlers/set_context_visibility.rs new file mode 100644 index 0000000000..ec51fffa58 --- /dev/null +++ b/crates/context/src/handlers/set_context_visibility.rs @@ -0,0 +1,157 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::SetContextVisibilityRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetContextVisibilityRequest { + group_id, + context_id, + mode, + requester, + }: SetContextVisibilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + // Context visibility can be set by admin or the context creator + let is_admin = group_store::is_group_admin(&self.datastore, &group_id, &requester)?; + if !is_admin { + // Check if requester is the context creator + if let Some((_, creator_bytes)) = + group_store::get_context_visibility(&self.datastore, &group_id, &context_id)? + { + if creator_bytes != *requester { + bail!("only admin or context creator can set visibility"); + } + } else { + bail!("context visibility not found for context in group"); + } + } + + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + let mode_u8 = match mode { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + + // Preserve creator from existing visibility, or use requester as creator + let creator = + group_store::get_context_visibility(&self.datastore, &group_id, &context_id)? + .map(|(_, c)| c) + .unwrap_or(*requester); + + group_store::set_context_visibility( + &self.datastore, + &group_id, + &context_id, + mode_u8, + creator, + )?; + + // Auto-add creator to allowlist when switching to Restricted + if mode == calimero_context_config::VisibilityMode::Restricted { + let creator_pk = calimero_primitives::identity::PublicKey::from(creator); + if !group_store::check_context_allowlist( + &self.datastore, + &group_id, + &context_id, + &creator_pk, + )? { + group_store::add_to_context_allowlist( + &self.datastore, + &group_id, + &context_id, + &creator_pk, + )?; + } + } + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let broadcast_mode_u8 = match mode { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + let broadcast_creator: [u8; 32] = + group_store::get_context_visibility(&self.datastore, &group_id, &context_id) + .ok() + .flatten() + .map(|(_, c)| c) + .unwrap_or(*requester); + + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + group_client + .set_context_visibility(context_id, mode) + .await?; + } + + info!(?group_id, %context_id, ?mode, "context visibility updated"); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::ContextVisibilitySet { + context_id: *context_id, + mode: broadcast_mode_u8, + creator: broadcast_creator, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/set_default_capabilities.rs b/crates/context/src/handlers/set_default_capabilities.rs new file mode 100644 index 0000000000..2cd0abab12 --- /dev/null +++ b/crates/context/src/handlers/set_default_capabilities.rs @@ -0,0 +1,102 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::SetDefaultCapabilitiesRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetDefaultCapabilitiesRequest { + group_id, + default_capabilities, + requester, + }: SetDefaultCapabilitiesRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + group_store::set_default_capabilities( + &self.datastore, + &group_id, + default_capabilities, + )?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + group_client + .set_default_capabilities(default_capabilities) + .await?; + } + + info!( + ?group_id, + default_capabilities, "default member capabilities updated" + ); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::DefaultCapabilitiesSet { + capabilities: default_capabilities, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/set_default_visibility.rs b/crates/context/src/handlers/set_default_visibility.rs new file mode 100644 index 0000000000..b092d0ec04 --- /dev/null +++ b/crates/context/src/handlers/set_default_visibility.rs @@ -0,0 +1,109 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::SetDefaultVisibilityRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetDefaultVisibilityRequest { + group_id, + default_visibility, + requester, + }: SetDefaultVisibilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + let mode_u8 = match default_visibility { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + + group_store::set_default_visibility(&self.datastore, &group_id, mode_u8)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let broadcast_mode_u8 = match default_visibility { + calimero_context_config::VisibilityMode::Open => 0u8, + calimero_context_config::VisibilityMode::Restricted => 1u8, + }; + + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + group_client + .set_default_visibility(default_visibility) + .await?; + } + + info!( + ?group_id, + ?default_visibility, + "default context visibility updated" + ); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::DefaultVisibilitySet { + mode: broadcast_mode_u8, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/set_group_alias.rs b/crates/context/src/handlers/set_group_alias.rs new file mode 100644 index 0000000000..eda9385aa3 --- /dev/null +++ b/crates/context/src/handlers/set_group_alias.rs @@ -0,0 +1,71 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::SetGroupAliasRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetGroupAliasRequest { + group_id, + alias, + requester, + }: SetGroupAliasRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + group_store::set_group_alias(&self.datastore, &group_id, &alias)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + info!( + ?group_id, + %alias, + "group alias set" + ); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::GroupAliasSet { alias }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/set_member_alias.rs b/crates/context/src/handlers/set_member_alias.rs new file mode 100644 index 0000000000..5f74bf8558 --- /dev/null +++ b/crates/context/src/handlers/set_member_alias.rs @@ -0,0 +1,78 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::SetMemberAliasRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetMemberAliasRequest { + group_id, + member, + alias, + requester, + }: SetMemberAliasRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + if requester != member { + bail!("members may only set their own alias"); + } + + group_store::set_member_alias(&self.datastore, &group_id, &member, &alias)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + info!( + ?group_id, + %member, + %alias, + "group member alias set" + ); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::MemberAliasSet { + member: *member, + alias, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/set_member_capabilities.rs b/crates/context/src/handlers/set_member_capabilities.rs new file mode 100644 index 0000000000..a66a52b3c5 --- /dev/null +++ b/crates/context/src/handlers/set_member_capabilities.rs @@ -0,0 +1,103 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_config::repr::ReprTransmute; +use calimero_context_primitives::group::SetMemberCapabilitiesRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; +use tracing::info; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SetMemberCapabilitiesRequest { + group_id, + member, + capabilities, + requester, + }: SetMemberCapabilitiesRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + if signing_key.is_none() { + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + } + + if group_store::get_group_member_role(&self.datastore, &group_id, &member)?.is_none() { + bail!("identity is not a member of group '{group_id:?}'"); + } + + group_store::set_member_capability(&self.datastore, &group_id, &member, capabilities)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + if let Some(ref sk) = signing_key { + let _ = + group_store::store_group_signing_key(&self.datastore, &group_id, &requester, sk); + } + + let node_client = self.node_client.clone(); + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + + ActorResponse::r#async( + async move { + if let Some(client_result) = group_client_result { + let mut group_client = client_result?; + let signer_id: calimero_context_config::types::SignerId = member.rt()?; + group_client + .set_member_capabilities(signer_id, capabilities) + .await?; + } + + info!(?group_id, %member, capabilities, "member capabilities updated"); + + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::MemberCapabilitySet { + member: *member, + capabilities, + }, + ) + .await; + + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/store_context_alias.rs b/crates/context/src/handlers/store_context_alias.rs new file mode 100644 index 0000000000..92666518fa --- /dev/null +++ b/crates/context/src/handlers/store_context_alias.rs @@ -0,0 +1,22 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreContextAliasRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreContextAliasRequest { + group_id, + context_id, + alias, + }: StoreContextAliasRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = + group_store::set_context_alias(&self.datastore, &group_id, &context_id, &alias); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_context_allowlist.rs b/crates/context/src/handlers/store_context_allowlist.rs new file mode 100644 index 0000000000..5121a7cd53 --- /dev/null +++ b/crates/context/src/handlers/store_context_allowlist.rs @@ -0,0 +1,32 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreContextAllowlistRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreContextAllowlistRequest { + group_id, + context_id, + members, + }: StoreContextAllowlistRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = (|| -> eyre::Result<()> { + group_store::clear_context_allowlist(&self.datastore, &group_id, &context_id)?; + for member in &members { + group_store::add_to_context_allowlist( + &self.datastore, + &group_id, + &context_id, + member, + )?; + } + Ok(()) + })(); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_context_visibility.rs b/crates/context/src/handlers/store_context_visibility.rs new file mode 100644 index 0000000000..77f92a709e --- /dev/null +++ b/crates/context/src/handlers/store_context_visibility.rs @@ -0,0 +1,28 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreContextVisibilityRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreContextVisibilityRequest { + group_id, + context_id, + mode, + creator, + }: StoreContextVisibilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = group_store::set_context_visibility( + &self.datastore, + &group_id, + &context_id, + mode, + *creator, + ); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_default_capabilities.rs b/crates/context/src/handlers/store_default_capabilities.rs new file mode 100644 index 0000000000..8d0e5edc9c --- /dev/null +++ b/crates/context/src/handlers/store_default_capabilities.rs @@ -0,0 +1,21 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreDefaultCapabilitiesRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreDefaultCapabilitiesRequest { + group_id, + capabilities, + }: StoreDefaultCapabilitiesRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = + group_store::set_default_capabilities(&self.datastore, &group_id, capabilities); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_default_visibility.rs b/crates/context/src/handlers/store_default_visibility.rs new file mode 100644 index 0000000000..f13ab6f50d --- /dev/null +++ b/crates/context/src/handlers/store_default_visibility.rs @@ -0,0 +1,17 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreDefaultVisibilityRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreDefaultVisibilityRequest { group_id, mode }: StoreDefaultVisibilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = group_store::set_default_visibility(&self.datastore, &group_id, mode); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_group_alias.rs b/crates/context/src/handlers/store_group_alias.rs new file mode 100644 index 0000000000..305dd9c661 --- /dev/null +++ b/crates/context/src/handlers/store_group_alias.rs @@ -0,0 +1,17 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreGroupAliasRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreGroupAliasRequest { group_id, alias }: StoreGroupAliasRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = group_store::set_group_alias(&self.datastore, &group_id, &alias); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_group_context.rs b/crates/context/src/handlers/store_group_context.rs new file mode 100644 index 0000000000..5a6623b224 --- /dev/null +++ b/crates/context/src/handlers/store_group_context.rs @@ -0,0 +1,21 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreGroupContextRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreGroupContextRequest { + group_id, + context_id, + }: StoreGroupContextRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = + group_store::register_context_in_group(&self.datastore, &group_id, &context_id); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_member_alias.rs b/crates/context/src/handlers/store_member_alias.rs new file mode 100644 index 0000000000..f982160933 --- /dev/null +++ b/crates/context/src/handlers/store_member_alias.rs @@ -0,0 +1,21 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreMemberAliasRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreMemberAliasRequest { + group_id, + member, + alias, + }: StoreMemberAliasRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = group_store::set_member_alias(&self.datastore, &group_id, &member, &alias); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/store_member_capability.rs b/crates/context/src/handlers/store_member_capability.rs new file mode 100644 index 0000000000..33e2d1c977 --- /dev/null +++ b/crates/context/src/handlers/store_member_capability.rs @@ -0,0 +1,22 @@ +use actix::{ActorResponse, Handler, Message}; +use calimero_context_primitives::group::StoreMemberCapabilityRequest; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + StoreMemberCapabilityRequest { + group_id, + member, + capabilities, + }: StoreMemberCapabilityRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let result = + group_store::set_member_capability(&self.datastore, &group_id, &member, capabilities); + ActorResponse::reply(result) + } +} diff --git a/crates/context/src/handlers/sync_group.rs b/crates/context/src/handlers/sync_group.rs new file mode 100644 index 0000000000..7d3f21eedc --- /dev/null +++ b/crates/context/src/handlers/sync_group.rs @@ -0,0 +1,103 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::{SyncGroupRequest, SyncGroupResponse}; +use tracing::{info, warn}; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + SyncGroupRequest { + group_id, + requester: _, + protocol, + network_id, + contract_id, + }: SyncGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + // Resolve contract coordinates: prefer request params, fall back to config + let (protocol, network_id, contract_id) = match (protocol, network_id, contract_id) { + (Some(p), Some(n), Some(c)) => (p, n, c), + _ => { + let params = match self.external_config.params.get("near") { + Some(p) => p, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "no 'near' protocol config and no coordinates provided" + ))); + } + }; + ( + "near".to_owned(), + params.network.clone(), + params.contract_id.clone(), + ) + } + }; + + let datastore = self.datastore.clone(); + let context_client = self.context_client.clone(); + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + let (meta, group_info) = group_store::sync_group_state_from_contract( + &datastore, + &context_client, + &group_id, + &protocol, + &network_id, + &contract_id, + ) + .await?; + + // Check if target app blob is available (informational only). + // Blob fetching is handled by join_group_context when the node + // actually joins a context, which establishes P2P mesh connectivity. + if let Some((blob_id, _source, _size)) = + group_store::extract_application_blob_info(&group_info.target_application) + { + if !node_client.has_blob(&blob_id)? { + info!( + ?group_id, + %blob_id, + "target app blob not available locally; \ + it will be fetched when joining a context" + ); + } + } + + let contexts = + group_store::enumerate_group_contexts(&datastore, &group_id, 0, usize::MAX)?; + for context_id in &contexts { + if let Err(err) = context_client.sync_context_config(*context_id, None).await { + warn!( + ?group_id, + %context_id, + ?err, + "failed to sync context while syncing group" + ); + } + } + + let member_count = group_store::count_group_members(&datastore, &group_id)? as u64; + let context_count = + group_store::count_group_contexts(&datastore, &group_id)? as u64; + + info!(?group_id, "group state synced from contract"); + + Ok(SyncGroupResponse { + group_id, + app_key: meta.app_key, + target_application_id: meta.target_application_id, + member_count, + context_count, + }) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/update_application.rs b/crates/context/src/handlers/update_application.rs index 8cbb6f3086..656604ffd6 100644 --- a/crates/context/src/handlers/update_application.rs +++ b/crates/context/src/handlers/update_application.rs @@ -390,7 +390,7 @@ fn verify_appkey_continuity( /// 3. Executes the migration function /// 4. Writes returned state bytes to root storage key /// 5. Updates context metadata and triggers sync -async fn update_application_with_migration( +pub(crate) async fn update_application_with_migration( datastore: calimero_store::Store, node_client: NodeClient, context_client: ContextClient, diff --git a/crates/context/src/handlers/update_group_settings.rs b/crates/context/src/handlers/update_group_settings.rs new file mode 100644 index 0000000000..4564c98a1a --- /dev/null +++ b/crates/context/src/handlers/update_group_settings.rs @@ -0,0 +1,77 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::UpdateGroupSettingsRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + UpdateGroupSettingsRequest { + group_id, + requester, + upgrade_policy, + }: UpdateGroupSettingsRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Auto-store node signing key so it's available for authorization checks + if let Some((_, node_sk)) = node_identity { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &requester, + &node_sk, + ); + } + + if let Err(err) = (|| -> eyre::Result<()> { + let Some(mut meta) = group_store::load_group_meta(&self.datastore, &group_id)? else { + bail!("group '{group_id:?}' not found"); + }; + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + + meta.upgrade_policy = upgrade_policy; + group_store::save_group_meta(&self.datastore, &group_id, &meta)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::SettingsUpdated, + ) + .await; + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/update_member_role.rs b/crates/context/src/handlers/update_member_role.rs new file mode 100644 index 0000000000..e42173039b --- /dev/null +++ b/crates/context/src/handlers/update_member_role.rs @@ -0,0 +1,100 @@ +use actix::{ActorResponse, Handler, Message, WrapFuture}; +use calimero_context_primitives::group::UpdateMemberRoleRequest; +use calimero_node_primitives::sync::GroupMutationKind; +use calimero_primitives::context::GroupMemberRole; +use eyre::bail; + +use crate::group_store; +use crate::ContextManager; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + UpdateMemberRoleRequest { + group_id, + identity, + new_role, + requester, + }: UpdateMemberRoleRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + if let Err(err) = (|| -> eyre::Result<()> { + if group_store::load_group_meta(&self.datastore, &group_id)?.is_none() { + bail!("group '{group_id:?}' not found"); + } + + group_store::require_group_admin(&self.datastore, &group_id, &requester)?; + + // Auto-store signing key if provided + if let Some(ref sk) = signing_key { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &requester, + sk, + ); + } + + group_store::require_group_signing_key(&self.datastore, &group_id, &requester)?; + + let Some(current_role) = + group_store::get_group_member_role(&self.datastore, &group_id, &identity)? + else { + bail!("identity is not a member of group '{group_id:?}'"); + }; + + if current_role == new_role { + return Ok(()); + } + + if current_role == GroupMemberRole::Admin && new_role == GroupMemberRole::Member { + let admin_count = group_store::count_group_admins(&self.datastore, &group_id)?; + if admin_count <= 1 { + bail!("cannot demote the last admin of group '{group_id:?}'"); + } + } + + group_store::add_group_member(&self.datastore, &group_id, &identity, new_role)?; + + Ok(()) + })() { + return ActorResponse::reply(Err(err)); + } + + let node_client = self.node_client.clone(); + + ActorResponse::r#async( + async move { + let _ = node_client + .broadcast_group_mutation( + group_id.to_bytes(), + GroupMutationKind::MemberRoleUpdated, + ) + .await; + Ok(()) + } + .into_actor(self), + ) + } +} diff --git a/crates/context/src/handlers/upgrade_group.rs b/crates/context/src/handlers/upgrade_group.rs new file mode 100644 index 0000000000..94fb1b212c --- /dev/null +++ b/crates/context/src/handlers/upgrade_group.rs @@ -0,0 +1,703 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use actix::{ActorFutureExt, ActorResponse, AsyncContext, Handler, Message, WrapFuture}; +use calimero_context_config::types::ContextGroupId; +use calimero_context_primitives::group::{UpgradeGroupRequest, UpgradeGroupResponse}; +use calimero_context_primitives::messages::MigrationParams; +use calimero_node_primitives::sync::GroupMutationKind; +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::{ContextId, UpgradePolicy}; +use calimero_primitives::identity::PublicKey; +use calimero_store::key::{self, GroupUpgradeStatus, GroupUpgradeValue}; +use eyre::bail; +use tracing::{debug, error, info, warn}; + +use crate::{group_store, ContextManager}; + +impl Handler for ContextManager { + type Result = ActorResponse::Result>; + + fn handle( + &mut self, + UpgradeGroupRequest { + group_id, + target_application_id, + requester, + migration, + }: UpgradeGroupRequest, + _ctx: &mut Self::Context, + ) -> Self::Result { + let node_identity = self.node_group_identity(); + + // Resolve requester: use provided value or fall back to node group identity + let requester = match requester { + Some(pk) => pk, + None => match node_identity { + Some((pk, _)) => pk, + None => { + return ActorResponse::reply(Err(eyre::eyre!( + "requester not provided and node has no configured group identity" + ))) + } + }, + }; + + // Resolve signing_key from node identity key + let node_sk = node_identity.map(|(_, sk)| sk); + let signing_key = node_sk; + + // --- Synchronous validation --- + let preamble = match validate_upgrade( + &self.datastore, + &group_id, + &target_application_id, + &requester, + signing_key.is_some(), + migration.is_some(), + ) { + Ok(p) => p, + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let UpgradePreamble { + canary_context_id, + total_contexts, + upgrade_policy, + from_version, + to_version, + } = preamble; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let migration_bytes = migration.as_ref().map(|m| m.method.as_bytes().to_vec()); + let migration_method_str = migration.as_ref().map(|m| m.method.clone()); + + // Auto-store signing key ONLY when the requester IS the node's own identity + if let (Some(sk), Some((node_pk, _))) = (signing_key, node_identity) { + if requester == node_pk { + let _ = group_store::store_group_signing_key( + &self.datastore, + &group_id, + &requester, + &sk, + ); + } + } + + // Build contract call if signing_key is available (or from stored key) + let effective_signing_key = signing_key.or_else(|| { + group_store::get_group_signing_key(&self.datastore, &group_id, &requester) + .ok() + .flatten() + }); + let group_client_result = effective_signing_key.map(|sk| self.group_client(group_id, sk)); + let app_meta_for_contract = if group_client_result.is_some() { + match (|| { + let handle = self.datastore.handle(); + let key = key::ApplicationMeta::new(target_application_id); + handle + .get(&key)? + .ok_or_else(|| eyre::eyre!("target application not found")) + })() { + Ok(meta) => Some(meta), + Err(err) => return ActorResponse::reply(Err(err)), + } + } else { + None + }; + + let node_client = self.node_client.clone(); + + // --- LazyOnAccess: update target and return without canary/propagator --- + // Contexts will be upgraded individually on their next execution. + // Launching a propagator would race with the lazy mechanism and could + // invoke migration functions twice on the same context. + // + // Save the upgrade record as Completed immediately — InProgress serves + // no purpose for lazy upgrades (no propagator runs) and would + // permanently block future upgrades since nothing transitions it out. + if matches!(upgrade_policy, UpgradePolicy::LazyOnAccess) { + let datastore = self.datastore.clone(); + return ActorResponse::r#async( + async move { + // Call contract if signing_key was provided + if let (Some(client_result), Some(ref app_meta)) = + (group_client_result, &app_meta_for_contract) + { + let mut group_client = client_result?; + let contract_app = super::create_group::build_contract_application( + &target_application_id, + app_meta, + )?; + group_client + .set_group_target(contract_app, migration_method_str.clone()) + .await?; + } + + let mut meta = group_store::load_group_meta(&datastore, &group_id)? + .ok_or_else(|| eyre::eyre!("group not found"))?; + meta.target_application_id = target_application_id; + meta.migration = migration_bytes.clone(); + group_store::save_group_meta(&datastore, &group_id, &meta)?; + + // LazyOnAccess: contexts upgrade individually on demand; there is no single + // "all done" moment, so completed_at is None. + let completed_status = GroupUpgradeStatus::Completed { completed_at: None }; + + let upgrade_value = GroupUpgradeValue { + from_version, + to_version, + migration: migration_bytes, + initiated_at: now, + initiated_by: requester, + status: completed_status.clone(), + }; + + group_store::save_group_upgrade(&datastore, &group_id, &upgrade_value)?; + + info!( + ?group_id, + %target_application_id, + "LazyOnAccess upgrade target set; contexts will upgrade on next access" + ); + + let contexts = group_store::enumerate_group_contexts( + &datastore, + &group_id, + 0, + usize::MAX, + )?; + let _ = node_client + .broadcast_group_mutation(group_id.to_bytes(), GroupMutationKind::Upgraded) + .await; + + // Announce target app blob on DHT for each group context so + // peer nodes can discover and fetch it during group sync. + if let Some(ref app_meta) = app_meta_for_contract { + let blob_id = app_meta.bytecode.blob_id(); + for context_id in &contexts { + if let Err(err) = node_client + .announce_blob_to_network(&blob_id, context_id, app_meta.size) + .await + { + warn!(%err, "failed to announce target app blob"); + } + } + } + + Ok(UpgradeGroupResponse { + group_id, + status: completed_status.into(), + }) + } + .into_actor(self), + ); + } + + // --- Persist InProgress BEFORE the async canary --- + // This prevents a concurrent UpgradeGroupRequest from passing + // validate_upgrade while the canary is still running. + let initial_status = GroupUpgradeStatus::InProgress { + total: total_contexts as u32, + completed: 0, + failed: 0, + }; + + let upgrade_value = GroupUpgradeValue { + from_version, + to_version, + migration: migration_bytes, + initiated_at: now, + initiated_by: requester, + status: initial_status.clone(), + }; + + if let Err(err) = + group_store::save_group_upgrade(&self.datastore, &group_id, &upgrade_value) + { + return ActorResponse::reply(Err(err.into())); + } + + // --- Async: run canary upgrade --- + let context_client = self.context_client.clone(); + let datastore = self.datastore.clone(); + let migrate_method = migration.as_ref().map(|m| m.method.clone()); + + let canary_signer = + match group_store::find_local_signing_identity(&self.datastore, &canary_context_id) { + Ok(Some(s)) => s, + Ok(None) => { + return ActorResponse::reply(Err(eyre::eyre!( + "no local signing identity for canary context {canary_context_id}" + ))) + } + Err(err) => return ActorResponse::reply(Err(err)), + }; + + let target_blob_info = app_meta_for_contract + .as_ref() + .map(|m| (m.bytecode.blob_id(), m.size)); + let migration_method_for_contract = migrate_method.clone(); + let canary_task = async move { + // Call set_group_target on contract before canary + if let (Some(client_result), Some(ref app_meta)) = + (group_client_result, &app_meta_for_contract) + { + let mut group_client = client_result?; + let contract_app = super::create_group::build_contract_application( + &target_application_id, + app_meta, + )?; + group_client + .set_group_target(contract_app, migration_method_for_contract) + .await?; + } + + context_client + .update_application( + &canary_context_id, + &target_application_id, + &canary_signer, + migrate_method, + ) + .await + } + .into_actor(self); + + let group_id_clone = group_id; + let context_client_for_propagator = self.context_client.clone(); + let datastore_for_propagator = self.datastore.clone(); + let node_client_for_gossip = self.node_client.clone(); + let datastore_for_gossip = self.datastore.clone(); + + ActorResponse::r#async(canary_task.map( + move |canary_result, act, ctx| match canary_result { + Err(err) => { + error!( + ?group_id, + canary=%canary_context_id, + ?err, + "canary upgrade failed, aborting group upgrade" + ); + // Clean up the InProgress record so the group can be retried + if let Err(cleanup_err) = + group_store::delete_group_upgrade(&datastore, &group_id_clone) + { + error!( + ?group_id, + ?cleanup_err, + "failed to clean up upgrade record after canary failure" + ); + } + Err(eyre::eyre!( + "canary upgrade failed on context {canary_context_id}: {err}" + )) + } + Ok(()) => { + info!( + ?group_id, + canary=%canary_context_id, + "canary upgrade succeeded, proceeding with group upgrade" + ); + + // Update group's target_application_id + let mut meta = group_store::load_group_meta(&datastore, &group_id_clone)? + .ok_or_else(|| eyre::eyre!("group not found after canary"))?; + + meta.target_application_id = target_application_id; + group_store::save_group_meta(&datastore, &group_id_clone, &meta)?; + + // Update InProgress status (canary = 1 completed) + let status = GroupUpgradeStatus::InProgress { + total: total_contexts as u32, + completed: 1, + failed: 0, + }; + + update_upgrade_status(&datastore, &group_id_clone, status.clone())?; + + // Gossip upgrade notification to peers + if let Ok(contexts) = group_store::enumerate_group_contexts( + &datastore_for_gossip, + &group_id_clone, + 0, + usize::MAX, + ) { + let nc = node_client_for_gossip; + let gid = group_id_clone.to_bytes(); + ctx.spawn( + async move { + let _ = nc + .broadcast_group_mutation(gid, GroupMutationKind::Upgraded) + .await; + + // Announce target app blob on DHT for peers + if let Some((blob_id, blob_size)) = target_blob_info { + for context_id in &contexts { + if let Err(err) = nc + .announce_blob_to_network( + &blob_id, context_id, blob_size, + ) + .await + { + warn!( + %err, + "failed to announce target app blob" + ); + } + } + } + } + .into_actor(act), + ); + } + + // Spawn propagator for remaining contexts + if total_contexts > 1 { + act.active_propagators.insert(group_id_clone); + + let propagator = propagate_upgrade( + context_client_for_propagator, + datastore_for_propagator, + group_id_clone, + target_application_id, + migration, + Some(canary_context_id), + 1, // canary already upgraded + ); + ctx.spawn(propagator.into_actor(act).map(move |_, act, _| { + act.active_propagators.remove(&group_id_clone); + })); + } else { + // Only one context (the canary) — mark completed + let completed_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let completed_status = GroupUpgradeStatus::Completed { + completed_at: Some(completed_at), + }; + update_upgrade_status( + &datastore, + &group_id_clone, + completed_status.clone(), + )?; + + return Ok(UpgradeGroupResponse { + group_id: group_id_clone, + status: completed_status.into(), + }); + } + + Ok(UpgradeGroupResponse { + group_id: group_id_clone, + status: status.into(), + }) + } + }, + )) + } +} + +struct UpgradePreamble { + canary_context_id: ContextId, + total_contexts: usize, + upgrade_policy: UpgradePolicy, + from_version: String, + to_version: String, +} + +fn validate_upgrade( + datastore: &calimero_store::Store, + group_id: &ContextGroupId, + target_application_id: &ApplicationId, + requester: &PublicKey, + has_raw_signing_key: bool, + has_migration: bool, +) -> eyre::Result { + // 1. Group must exist + let meta = group_store::load_group_meta(datastore, group_id)? + .ok_or_else(|| eyre::eyre!("group not found"))?; + + // 2. Requester must be admin + group_store::require_group_admin(datastore, group_id, requester)?; + + // 3. Verify node holds the key (skip if raw key was provided) + if !has_raw_signing_key { + group_store::require_group_signing_key(datastore, group_id, requester)?; + } + + // 4. No active upgrade in progress + if let Some(existing) = group_store::load_group_upgrade(datastore, group_id)? { + if matches!(existing.status, GroupUpgradeStatus::InProgress { .. }) { + bail!("an upgrade is already in progress for this group"); + } + } + + // 5. Target must differ from current + if meta.target_application_id == *target_application_id && !has_migration { + bail!("group is already targeting this application and no migration was requested"); + } + + // 6. Group must have contexts + let contexts = group_store::enumerate_group_contexts(datastore, group_id, 0, usize::MAX)?; + if contexts.is_empty() { + bail!("group has no contexts to upgrade"); + } + + // 7. Select canary (first context, deterministic order) + let canary_context_id = contexts[0]; + + // 8. Read current and target application versions from ApplicationMeta. + // Use the group's current target_application_id as the "from" version — NOT the + // canary context's application. For LazyOnAccess, the canary may have already been + // lazily upgraded on its last execute, making its app_id == new target_application_id, + // which would produce from_version == to_version. + let handle = datastore.handle(); + + let from_version = handle + .get(&key::ApplicationMeta::new(meta.target_application_id))? + .map_or_else(|| "unknown".to_owned(), |app| String::from(app.version)); + + let to_version = handle + .get(&key::ApplicationMeta::new(*target_application_id))? + .map_or_else(|| "unknown".to_owned(), |app| String::from(app.version)); + + Ok(UpgradePreamble { + canary_context_id, + total_contexts: contexts.len(), + upgrade_policy: meta.upgrade_policy.clone(), + from_version, + to_version, + }) +} + +/// Maximum number of automatic retry rounds for failed context upgrades. +const MAX_AUTO_RETRIES: u32 = 3; + +/// Base delay between retry rounds (doubles each round: 5s, 10s, 20s). +const RETRY_BASE_DELAY_SECS: u64 = 5; + +pub(crate) async fn propagate_upgrade( + context_client: calimero_context_primitives::client::ContextClient, + datastore: calimero_store::Store, + group_id: ContextGroupId, + target_application_id: ApplicationId, + migration: Option, + skip_context: Option, + initial_completed: u32, +) { + let contexts = match group_store::enumerate_group_contexts(&datastore, &group_id, 0, usize::MAX) + { + Ok(c) => c, + Err(err) => { + error!( + ?group_id, + ?err, + "failed to enumerate contexts for propagation" + ); + return; + } + }; + + // Use actual enumerated count as the authoritative total so that + // contexts added/removed since the upgrade started are reflected. + let total_contexts = contexts.len(); + + // Build the list of contexts to upgrade (excluding the canary) + let mut pending: Vec = contexts + .into_iter() + .filter(|cid| skip_context.map_or(true, |skip| *cid != skip)) + .collect(); + + // If the canary was removed from the group between the initial upgrade + // and this enumeration, it won't appear in the list and shouldn't count + // toward completed — otherwise completed can exceed total. + let canary_in_group = pending.len() < total_contexts; + let mut completed: u32 = if canary_in_group { + initial_completed + } else { + 0 + }; + let mut failed: u32; + let mut attempt: u32 = 0; + + loop { + let mut next_pending = Vec::new(); + failed = 0; + + for context_id in &pending { + // Skip contexts already running the target application to avoid + // re-executing migrations on retry/recovery paths. + match context_client.get_context(context_id) { + Ok(Some(ctx)) + if ctx.application_id == target_application_id && migration.is_none() => + { + completed += 1; + debug!( + ?group_id, + %context_id, + "context already on target application, skipping" + ); + // Persist progress + let status = GroupUpgradeStatus::InProgress { + total: total_contexts as u32, + completed, + failed, + }; + if let Err(err) = update_upgrade_status(&datastore, &group_id, status) { + error!(?group_id, ?err, "failed to persist upgrade progress"); + } + continue; + } + _ => {} + } + + let migrate_method = migration.as_ref().map(|m| m.method.clone()); + + let signer = match group_store::find_local_signing_identity(&datastore, context_id) { + Ok(Some(s)) => s, + Ok(None) => { + warn!( + ?group_id, + %context_id, + "no local signing identity for context, skipping upgrade" + ); + failed += 1; + next_pending.push(*context_id); + continue; + } + Err(err) => { + warn!( + ?group_id, + %context_id, + ?err, + "failed to look up local signing identity, skipping upgrade" + ); + failed += 1; + next_pending.push(*context_id); + continue; + } + }; + + match context_client + .update_application(context_id, &target_application_id, &signer, migrate_method) + .await + { + Ok(()) => { + completed += 1; + debug!( + ?group_id, + %context_id, + completed, + total = total_contexts, + attempt, + "context upgraded successfully" + ); + } + Err(err) => { + failed += 1; + next_pending.push(*context_id); + warn!( + ?group_id, + %context_id, + ?err, + failed, + attempt, + "context upgrade failed" + ); + } + } + + // Persist progress after each context + let status = GroupUpgradeStatus::InProgress { + total: total_contexts as u32, + completed, + failed, + }; + + if let Err(err) = update_upgrade_status(&datastore, &group_id, status) { + error!(?group_id, ?err, "failed to persist upgrade progress"); + } + } + + // All succeeded — no retry needed + if next_pending.is_empty() { + break; + } + + attempt += 1; + + // Exhausted retry attempts + if attempt > MAX_AUTO_RETRIES { + warn!( + ?group_id, + failed = next_pending.len(), + attempts = attempt, + "exhausted auto-retry attempts, remaining failures left as InProgress" + ); + break; + } + + // Exponential backoff before retrying + let delay_secs = RETRY_BASE_DELAY_SECS * (1 << (attempt - 1)); + info!( + ?group_id, + failed = next_pending.len(), + attempt, + delay_secs, + "retrying failed context upgrades after delay" + ); + tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; + + // Reset failed count for next round and retry only the failures + pending = next_pending; + } + + // Final status + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let final_status = if failed == 0 { + GroupUpgradeStatus::Completed { + completed_at: Some(now), + } + } else { + // Keep as InProgress with the final counts so manual retry can pick it up + GroupUpgradeStatus::InProgress { + total: total_contexts as u32, + completed, + failed, + } + }; + + if let Err(err) = update_upgrade_status(&datastore, &group_id, final_status) { + error!(?group_id, ?err, "failed to persist final upgrade status"); + } + + info!( + ?group_id, + completed, + failed, + total = total_contexts, + attempts = attempt + 1, + "group upgrade propagation finished" + ); +} + +pub(crate) fn update_upgrade_status( + datastore: &calimero_store::Store, + group_id: &ContextGroupId, + status: GroupUpgradeStatus, +) -> eyre::Result<()> { + if let Some(mut upgrade) = group_store::load_group_upgrade(datastore, group_id)? { + upgrade.status = status; + group_store::save_group_upgrade(datastore, group_id, &upgrade)?; + } + Ok(()) +} diff --git a/crates/context/src/handlers/utils.rs b/crates/context/src/handlers/utils.rs index 69d0522115..070c5ef3e9 100644 --- a/crates/context/src/handlers/utils.rs +++ b/crates/context/src/handlers/utils.rs @@ -87,7 +87,15 @@ pub async fn process_context_mutations( // The seed is also not passed as it should not be transferred via host function // and should be generated instead. match context_client - .create_context(protocol.clone(), &app_id, None, init_args.clone(), None) + .create_context( + protocol.clone(), + &app_id, + None, + init_args.clone(), + None, + None, + None, + ) .await { Ok(response) => { @@ -144,7 +152,7 @@ pub async fn process_context_mutations( continue; } - match context_client.delete_context(&target_ctx).await { + match context_client.delete_context(&target_ctx, None).await { Ok(_) => { info!(%context_id, target=%target_ctx, "Context deleted successfully via WASM host function"); } diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs index 7106cea5c8..aa1a5d5874 100644 --- a/crates/context/src/lib.rs +++ b/crates/context/src/lib.rs @@ -1,16 +1,19 @@ #![expect(clippy::unwrap_in_result, reason = "Repr transmute")] #![allow(clippy::multiple_inherent_impl, reason = "better readability")] -use std::collections::{btree_map, BTreeMap}; +use std::collections::{btree_map, BTreeMap, HashSet}; use std::future::Future; use std::sync::Arc; -use actix::Actor; +use actix::{Actor, ActorFutureExt, AsyncContext, WrapFuture}; use calimero_context_config::client::config::ClientConfig as ExternalClientConfig; +use calimero_context_config::types::ContextGroupId; +use calimero_context_primitives::client::external::group::ExternalGroupClient; use calimero_context_primitives::client::ContextClient; use calimero_node_primitives::client::NodeClient; use calimero_primitives::application::{Application, ApplicationId}; -use calimero_primitives::context::{Context, ContextId}; +use calimero_primitives::context::{Context, ContextId, UpgradePolicy}; +use calimero_store::key::GroupUpgradeStatus; use calimero_store::Store; use either::Either; use prometheus_client::registry::Registry; @@ -20,6 +23,7 @@ use crate::metrics::Metrics; pub mod config; pub mod error; +pub mod group_store; pub mod handlers; mod metrics; @@ -53,6 +57,9 @@ pub struct ContextManager { /// Configuration for interacting with external blockchain contracts (e.g., NEAR). external_config: ExternalClientConfig, + /// Dedicated group identity keypair, decoupled from the NEAR signer key. + group_identity: Option, + /// An in-memory cache of active contexts (`ContextId` -> `ContextMeta`). /// This serves as a hot cache to avoid expensive disk I/O for frequently accessed contexts. // todo! potentially make this a dashmap::DashMap @@ -71,6 +78,11 @@ pub struct ContextManager { /// Prometheus metrics for monitoring the health and performance of the manager, /// such as number of active contexts, message processing latency, etc. metrics: Option, + + /// Groups that currently have a running upgrade propagator. Prevents the + /// manual retry handler from spawning a second propagator while an + /// existing one is still active (e.g. sleeping in its backoff delay). + active_propagators: HashSet, // // todo! when runtime let's us compile blobs separate from its // todo! execution, we can introduce a cached::TimedSizedCache @@ -94,6 +106,7 @@ impl ContextManager { node_client: NodeClient, context_client: ContextClient, external_config: ExternalClientConfig, + group_identity: Option, prometheus_registry: Option<&mut Registry>, ) -> Self { Self { @@ -101,13 +114,76 @@ impl ContextManager { node_client, context_client, external_config, + group_identity, contexts: BTreeMap::new(), applications: BTreeMap::new(), metrics: prometheus_registry.map(Metrics::new), + active_propagators: HashSet::new(), } } + + pub fn node_group_identity( + &self, + ) -> Option<(calimero_primitives::identity::PublicKey, [u8; 32])> { + let gi = self.group_identity.as_ref()?; + + let pk_str = gi.public_key.strip_prefix("ed25519:").or_else(|| { + tracing::warn!("node group identity: public_key missing 'ed25519:' prefix"); + None + })?; + let sk_str = gi.secret_key.strip_prefix("ed25519:").or_else(|| { + tracing::warn!("node group identity: secret_key missing 'ed25519:' prefix"); + None + })?; + + let pk_bytes: [u8; 32] = bs58::decode(pk_str) + .into_vec() + .ok() + .and_then(|v| v.try_into().ok()) + .or_else(|| { + tracing::warn!( + "node group identity: failed to decode public_key (bad base58 or wrong length)" + ); + None + })?; + let sk_bytes: [u8; 32] = bs58::decode(sk_str) + .into_vec() + .ok() + .and_then(|v| v.try_into().ok()) + .or_else(|| { + tracing::warn!( + "node group identity: failed to decode secret_key (bad base58 or wrong length)" + ); + None + })?; + + Some(( + calimero_primitives::identity::PublicKey::from(pk_bytes), + sk_bytes, + )) + } + + fn group_client( + &self, + group_id: ContextGroupId, + signing_key: [u8; 32], + ) -> eyre::Result { + let params = self + .external_config + .params + .get("near") + .ok_or_else(|| eyre::eyre!("no 'near' protocol config"))?; + + Ok(self.context_client.group_client( + group_id, + signing_key, + "near".to_owned(), + params.network.clone(), + params.contract_id.clone(), + )) + } } /// Implements the `Actor` trait for `ContextManager`, allowing it to run within the Actix framework. @@ -117,6 +193,96 @@ impl ContextManager { /// they are received, which is the core of the actor model's safety guarantee for its internal state. impl Actor for ContextManager { type Context = actix::Context; + + fn started(&mut self, ctx: &mut Self::Context) { + self.recover_in_progress_upgrades(ctx); + } +} + +impl ContextManager { + /// Scans the store for in-progress group upgrades and re-spawns + /// propagators for each. Called during actor startup for crash recovery. + fn recover_in_progress_upgrades(&mut self, ctx: &mut actix::Context) { + let upgrades = match group_store::enumerate_in_progress_upgrades(&self.datastore) { + Ok(u) => u, + Err(err) => { + tracing::error!( + ?err, + "failed to scan for in-progress upgrades during recovery" + ); + return; + } + }; + + if upgrades.is_empty() { + return; + } + + tracing::info!( + count = upgrades.len(), + "recovering in-progress group upgrades" + ); + + for (group_id, upgrade) in upgrades { + let (total, completed, failed) = match upgrade.status { + GroupUpgradeStatus::InProgress { + total, + completed, + failed, + } => (total, completed, failed), + _ => continue, // shouldn't happen given our filter + }; + + tracing::info!( + ?group_id, + total, + completed, + failed, + "re-spawning propagator for in-progress upgrade" + ); + + // Extract migration method from stored bytes + let migration = upgrade + .migration + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) + .map(|method| calimero_context_primitives::messages::MigrationParams { method }); + + let meta = match group_store::load_group_meta(&self.datastore, &group_id) { + Ok(Some(m)) => m, + Ok(None) => { + tracing::warn!(?group_id, "group not found during recovery, skipping"); + continue; + } + Err(err) => { + tracing::error!(?group_id, ?err, "failed to load group meta during recovery"); + continue; + } + }; + + // Skip LazyOnAccess groups — they upgrade contexts on-demand, not via propagator + if matches!(meta.upgrade_policy, UpgradePolicy::LazyOnAccess) { + tracing::debug!(?group_id, "skipping crash recovery for LazyOnAccess group"); + continue; + } + + self.active_propagators.insert(group_id); + + let propagator = handlers::upgrade_group::propagate_upgrade( + self.context_client.clone(), + self.datastore.clone(), + group_id, + meta.target_application_id, + migration, + None, // no canary skip on recovery — propagator's idempotency handles already-upgraded contexts + 0, // recovery: no canary assumption + ); + + ctx.spawn(propagator.into_actor(self).map(move |_, act, _| { + act.active_propagators.remove(&group_id); + })); + } + } } impl ContextMeta { diff --git a/crates/meroctl/src/cli.rs b/crates/meroctl/src/cli.rs index 7b1e8b5a0f..3cb29ac68f 100644 --- a/crates/meroctl/src/cli.rs +++ b/crates/meroctl/src/cli.rs @@ -21,6 +21,7 @@ mod app; mod blob; mod call; mod context; +mod group; mod node; mod peers; pub mod validation; @@ -29,6 +30,7 @@ use app::AppCommand; use blob::BlobCommand; use call::CallCommand; use context::ContextCommand; +use group::GroupCommand; use node::NodeCommand; use peers::PeersCommand; @@ -41,6 +43,9 @@ pub const EXAMPLES: &str = r" # List all contexts $ meroctl --node node1 context ls + # List all groups + $ meroctl --node node1 group ls + # List all blobs $ meroctl --node node1 blob ls "; @@ -77,6 +82,7 @@ pub enum SubCommands { App(AppCommand), Blob(BlobCommand), Context(ContextCommand), + Group(GroupCommand), Call(CallCommand), Peers(PeersCommand), #[command(subcommand)] @@ -148,9 +154,13 @@ impl RootCommand { SubCommands::App(application) => application.run(&mut environment).await, SubCommands::Blob(blob) => blob.run(&mut environment).await, SubCommands::Context(context) => context.run(&mut environment).await, + SubCommands::Group(group) => group.run(&mut environment).await, SubCommands::Call(call) => call.run(&mut environment).await, SubCommands::Peers(peers) => peers.run(&mut environment).await, - SubCommands::Node(node) => node.run(&environment).await, + SubCommands::Node(node) => { + node.run(&environment, self.args.node.as_deref(), &self.args.home) + .await + } }; if let Err(err) = result { diff --git a/crates/meroctl/src/cli/context/create.rs b/crates/meroctl/src/cli/context/create.rs index aeebe2c919..40cc74e962 100644 --- a/crates/meroctl/src/cli/context/create.rs +++ b/crates/meroctl/src/cli/context/create.rs @@ -67,6 +67,15 @@ pub struct CreateCommand { #[clap(long = "name", help = "Create an alias for the context")] pub context: Option>, + + #[clap(long, help = "Group ID (hex) to attach this context to")] + pub group_id: Option, + + #[clap(long, help = "Identity secret (hex) for signing group membership")] + pub identity_secret: Option, + + #[clap(long, help = "Human-readable alias for the context within the group")] + pub alias: Option, } impl CreateCommand { @@ -84,6 +93,9 @@ impl CreateCommand { protocol, identity, context, + group_id, + identity_secret, + alias, } => { let _ = create_context( environment, @@ -94,6 +106,9 @@ impl CreateCommand { protocol, identity, context, + group_id, + identity_secret, + alias, ) .await?; } @@ -106,6 +121,9 @@ impl CreateCommand { protocol, identity, context, + group_id, + identity_secret, + alias, } => { // Validate file exists before watching validate_file_exists(path.as_std_path())?; @@ -133,6 +151,9 @@ impl CreateCommand { protocol, identity, context, + group_id, + identity_secret, + alias, ) .await?; @@ -162,6 +183,9 @@ pub async fn create_context( protocol: String, identity: Option>, context: Option>, + group_id: Option, + identity_secret: Option, + alias: Option, ) -> Result<(ContextId, PublicKey)> { let response: GetApplicationResponse = client.get_application(&application_id).await?; @@ -169,12 +193,15 @@ pub async fn create_context( bail!("Application is not installed on node.") } - let request = CreateContextRequest::new( + let mut request = CreateContextRequest::new( protocol, application_id, context_seed, params.map(String::into_bytes).unwrap_or_default(), + group_id, + identity_secret, ); + request.alias = alias; let response: CreateContextResponse = client.create_context(request).await?; diff --git a/crates/meroctl/src/cli/context/delete.rs b/crates/meroctl/src/cli/context/delete.rs index 5ddb25ed88..60c340316b 100644 --- a/crates/meroctl/src/cli/context/delete.rs +++ b/crates/meroctl/src/cli/context/delete.rs @@ -1,15 +1,22 @@ use calimero_primitives::alias::Alias; use calimero_primitives::context::ContextId; +use calimero_primitives::identity::PublicKey; use clap::Parser; use eyre::{OptionExt, Result}; use crate::cli::Environment; -#[derive(Copy, Clone, Debug, Parser)] +#[derive(Clone, Debug, Parser)] #[command(about = "Delete a context")] pub struct DeleteCommand { #[clap(name = "CONTEXT", help = "The context to delete")] pub context: Alias, + + #[clap( + long, + help = "Identity (public key) of the requester. Required when deleting a group context; must be a group admin." + )] + pub requester: Option, } impl DeleteCommand { @@ -23,7 +30,7 @@ impl DeleteCommand { .copied() .ok_or_eyre("unable to resolve")?; - let response = client.delete_context(&context_id).await?; + let response = client.delete_context(&context_id, self.requester).await?; environment.output.write(&response); diff --git a/crates/meroctl/src/cli/group.rs b/crates/meroctl/src/cli/group.rs new file mode 100644 index 0000000000..56042f4a21 --- /dev/null +++ b/crates/meroctl/src/cli/group.rs @@ -0,0 +1,100 @@ +use clap::{Parser, Subcommand}; +use const_format::concatcp; +use eyre::Result; + +use crate::cli::Environment; + +pub mod contexts; +pub mod create; +pub mod delete; +pub mod get; +pub mod invite; +pub mod join; +pub mod join_group_context; +pub mod list; +pub mod members; +pub mod settings; +pub mod signing_key; +pub mod sync; +pub mod update; +pub mod upgrade; + +pub const EXAMPLES: &str = r" + # List all groups + $ meroctl --node node1 group ls + + # Create a new group + $ meroctl --node node1 group create --app-key --application-id --admin-identity + + # Get group info + $ meroctl --node node1 group get + + # Create an invitation to join a group + $ meroctl --node node1 group invite --requester + + # Join a group using an invitation payload + $ meroctl --node node1 group join '' --joiner-identity + + # Register a signing key for a group admin + $ meroctl --node node1 group signing-key register + + # List members of a group + $ meroctl --node node1 group members list + + # List contexts in a group + $ meroctl --node node1 group contexts list +"; + +#[derive(Debug, Parser)] +#[command(about = "Command for managing groups")] +#[command(after_help = concatcp!( + "Examples:", + EXAMPLES +))] +pub struct GroupCommand { + #[command(subcommand)] + pub subcommand: GroupSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum GroupSubCommands { + #[command(alias = "ls")] + List(list::ListCommand), + Create(create::CreateCommand), + Get(get::GetCommand), + #[command(alias = "del")] + Delete(delete::DeleteCommand), + Update(update::UpdateCommand), + Members(members::MembersCommand), + Contexts(contexts::ContextsCommand), + Invite(invite::InviteCommand), + Join(join::JoinCommand), + #[command(alias = "signing-key")] + SigningKey(signing_key::SigningKeyCommand), + Upgrade(upgrade::UpgradeCommand), + Sync(sync::SyncCommand), + #[command(alias = "join-group-context")] + JoinGroupContext(join_group_context::JoinGroupContextCommand), + Settings(settings::SettingsCommand), +} + +impl GroupCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + GroupSubCommands::List(cmd) => cmd.run(environment).await, + GroupSubCommands::Create(cmd) => cmd.run(environment).await, + GroupSubCommands::Get(cmd) => cmd.run(environment).await, + GroupSubCommands::Delete(cmd) => cmd.run(environment).await, + GroupSubCommands::Update(cmd) => cmd.run(environment).await, + GroupSubCommands::Members(cmd) => cmd.run(environment).await, + GroupSubCommands::Contexts(cmd) => cmd.run(environment).await, + GroupSubCommands::Invite(cmd) => cmd.run(environment).await, + GroupSubCommands::Join(cmd) => cmd.run(environment).await, + GroupSubCommands::SigningKey(cmd) => cmd.run(environment).await, + GroupSubCommands::Upgrade(cmd) => cmd.run(environment).await, + GroupSubCommands::Sync(cmd) => cmd.run(environment).await, + GroupSubCommands::JoinGroupContext(cmd) => cmd.run(environment).await, + GroupSubCommands::Settings(cmd) => cmd.run(environment).await, + } + } +} diff --git a/crates/meroctl/src/cli/group/contexts.rs b/crates/meroctl/src/cli/group/contexts.rs new file mode 100644 index 0000000000..596a2a9261 --- /dev/null +++ b/crates/meroctl/src/cli/group/contexts.rs @@ -0,0 +1,356 @@ +use calimero_primitives::context::ContextId; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::{ + DetachContextFromGroupApiRequest, ManageContextAllowlistApiRequest, + SetContextVisibilityApiRequest, +}; +use clap::{Parser, Subcommand, ValueEnum}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, ValueEnum)] +pub enum VisibilityModeArg { + Open, + Restricted, +} + +#[derive(Debug, Parser)] +#[command(about = "Manage contexts within a group")] +pub struct ContextsCommand { + #[command(subcommand)] + pub subcommand: ContextsSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum ContextsSubCommands { + #[command(alias = "ls", about = "List all contexts in a group")] + List(ListGroupContextsCommand), + #[command(about = "Detach a context from a group")] + Detach(DetachContextCommand), + #[command( + alias = "set-vis", + about = "Set visibility mode for a context (open or restricted)" + )] + SetVisibility(SetVisibilityCommand), + #[command(alias = "get-vis", about = "Get visibility mode for a context")] + GetVisibility(GetVisibilityCommand), + #[command(about = "Manage the allowlist for a restricted context")] + Allowlist(AllowlistCommand), +} + +impl ContextsCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + ContextsSubCommands::List(cmd) => cmd.run(environment).await, + ContextsSubCommands::Detach(cmd) => cmd.run(environment).await, + ContextsSubCommands::SetVisibility(cmd) => cmd.run(environment).await, + ContextsSubCommands::GetVisibility(cmd) => cmd.run(environment).await, + ContextsSubCommands::Allowlist(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "List all contexts in a group")] +pub struct ListGroupContextsCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, +} + +impl ListGroupContextsCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.list_group_contexts(&self.group_id).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Detach a context from a group")] +pub struct DetachContextCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl DetachContextCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = DetachContextFromGroupApiRequest { + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .detach_context_from_group(&self.group_id, &self.context_id.to_string(), request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Set visibility mode for a context in a group")] +pub struct SetVisibilityCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap(long, value_enum, help = "Visibility mode: open or restricted")] + pub mode: VisibilityModeArg, + + #[clap( + long, + help = "Public key of the requester. Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl SetVisibilityCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let mode_str = match self.mode { + VisibilityModeArg::Open => "open", + VisibilityModeArg::Restricted => "restricted", + }; + + let request = SetContextVisibilityApiRequest { + mode: mode_str.to_owned(), + requester: self.requester, + }; + + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let client = environment.client()?; + let response = client + .set_context_visibility(&self.group_id, &context_id_hex, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Get visibility mode for a context in a group")] +pub struct GetVisibilityCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, +} + +impl GetVisibilityCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let client = environment.client()?; + let response = client + .get_context_visibility(&self.group_id, &context_id_hex) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +// ---- Allowlist subcommands ---- + +#[derive(Debug, Parser)] +#[command(about = "Manage context allowlist")] +pub struct AllowlistCommand { + #[command(subcommand)] + pub subcommand: AllowlistSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum AllowlistSubCommands { + #[command(alias = "ls", about = "List members on the allowlist")] + List(ListAllowlistCommand), + #[command(about = "Add members to the allowlist")] + Add(AddAllowlistCommand), + #[command(about = "Remove members from the allowlist")] + Remove(RemoveAllowlistCommand), + #[command(about = "Check if an identity is on the allowlist")] + Check(AllowlistCheckCommand), +} + +impl AllowlistCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + AllowlistSubCommands::List(cmd) => cmd.run(environment).await, + AllowlistSubCommands::Add(cmd) => cmd.run(environment).await, + AllowlistSubCommands::Remove(cmd) => cmd.run(environment).await, + AllowlistSubCommands::Check(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "List members on the context allowlist")] +pub struct ListAllowlistCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, +} + +impl ListAllowlistCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let client = environment.client()?; + let response = client + .get_context_allowlist(&self.group_id, &context_id_hex) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Add members to the context allowlist")] +pub struct AddAllowlistCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap( + name = "MEMBERS", + required = true, + help = "Public keys of members to add (space-separated)" + )] + pub members: Vec, + + #[clap( + long, + help = "Public key of the requester. Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl AddAllowlistCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let request = ManageContextAllowlistApiRequest { + add: self.members, + remove: vec![], + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .manage_context_allowlist(&self.group_id, &context_id_hex, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Remove members from the context allowlist")] +pub struct RemoveAllowlistCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap( + name = "MEMBERS", + required = true, + help = "Public keys of members to remove (space-separated)" + )] + pub members: Vec, + + #[clap( + long, + help = "Public key of the requester. Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl RemoveAllowlistCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let request = ManageContextAllowlistApiRequest { + add: vec![], + remove: self.members, + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .manage_context_allowlist(&self.group_id, &context_id_hex, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Check if an identity is on the context allowlist")] +pub struct AllowlistCheckCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap(name = "IDENTITY", help = "Public key of the identity to check")] + pub identity: PublicKey, +} + +impl AllowlistCheckCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let client = environment.client()?; + let response = client + .get_context_allowlist(&self.group_id, &context_id_hex) + .await?; + + let on_list = response.data.contains(&self.identity); + if on_list { + println!( + "{} IS on the allowlist for context {}", + self.identity, self.context_id + ); + } else { + println!( + "{} is NOT on the allowlist for context {}", + self.identity, self.context_id + ); + } + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/create.rs b/crates/meroctl/src/cli/group/create.rs new file mode 100644 index 0000000000..be4de06118 --- /dev/null +++ b/crates/meroctl/src/cli/group/create.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::UpgradePolicy; +use calimero_server_primitives::admin::CreateGroupApiRequest; +use clap::{Parser, ValueEnum}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, ValueEnum)] +pub enum UpgradePolicyArg { + Automatic, + LazyOnAccess, + Coordinated, +} + +#[derive(Debug, Parser)] +#[command(about = "Create a new group")] +pub struct CreateCommand { + #[clap( + long, + help = "Hex-encoded 32-byte app key for the group (auto-generated if not provided)" + )] + pub app_key: Option, + + #[clap(long, help = "The application ID to associate with the group")] + pub application_id: ApplicationId, + + #[clap( + long, + value_enum, + default_value = "lazy-on-access", + help = "Upgrade policy for the group" + )] + pub upgrade_policy: UpgradePolicyArg, + + #[clap(long, help = "Deadline in seconds for coordinated upgrade policy")] + pub deadline_secs: Option, + + #[clap( + long, + help = "Optional group ID (hex-encoded 32 bytes); generated if not provided" + )] + pub group_id: Option, +} + +impl CreateCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let upgrade_policy = match self.upgrade_policy { + UpgradePolicyArg::Automatic => UpgradePolicy::Automatic, + UpgradePolicyArg::LazyOnAccess => UpgradePolicy::LazyOnAccess, + UpgradePolicyArg::Coordinated => UpgradePolicy::Coordinated { + deadline: self.deadline_secs.map(Duration::from_secs), + }, + }; + + let request = CreateGroupApiRequest { + group_id: self.group_id, + app_key: self.app_key, + application_id: self.application_id, + upgrade_policy, + alias: None, + }; + + let client = environment.client()?; + let response = client.create_group(request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/delete.rs b/crates/meroctl/src/cli/group/delete.rs new file mode 100644 index 0000000000..186b561c57 --- /dev/null +++ b/crates/meroctl/src/cli/group/delete.rs @@ -0,0 +1,34 @@ +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::DeleteGroupApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Delete a group")] +pub struct DeleteCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl DeleteCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = DeleteGroupApiRequest { + requester: self.requester, + }; + + let client = environment.client()?; + let response = client.delete_group(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/get.rs b/crates/meroctl/src/cli/group/get.rs new file mode 100644 index 0000000000..540e5d38c2 --- /dev/null +++ b/crates/meroctl/src/cli/group/get.rs @@ -0,0 +1,22 @@ +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Get information about a group")] +pub struct GetCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, +} + +impl GetCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.get_group_info(&self.group_id).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/invite.rs b/crates/meroctl/src/cli/group/invite.rs new file mode 100644 index 0000000000..c3ebcf86bc --- /dev/null +++ b/crates/meroctl/src/cli/group/invite.rs @@ -0,0 +1,43 @@ +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::CreateGroupInvitationApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Create a group invitation")] +pub struct InviteCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, + + #[clap( + long, + help = "On-chain block height after which the invitation expires (defaults to 999_999_999)" + )] + pub expiration_block_height: Option, +} + +impl InviteCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = CreateGroupInvitationApiRequest { + requester: self.requester, + expiration_block_height: self.expiration_block_height, + }; + + let client = environment.client()?; + let response = client + .create_group_invitation(&self.group_id, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/join.rs b/crates/meroctl/src/cli/group/join.rs new file mode 100644 index 0000000000..d15d1770a7 --- /dev/null +++ b/crates/meroctl/src/cli/group/join.rs @@ -0,0 +1,35 @@ +use calimero_context_config::types::SignedGroupOpenInvitation; +use calimero_server_primitives::admin::JoinGroupApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Join a group using an invitation")] +pub struct JoinCommand { + #[clap( + name = "INVITATION_JSON", + help = "The invitation JSON (obtained from 'meroctl group invite')" + )] + pub invitation_json: String, +} + +impl JoinCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let invitation: SignedGroupOpenInvitation = serde_json::from_str(&self.invitation_json) + .map_err(|e| eyre::eyre!("invalid invitation JSON: {e}"))?; + + let request = JoinGroupApiRequest { + invitation, + group_alias: None, + }; + + let client = environment.client()?; + let response = client.join_group(request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/join_group_context.rs b/crates/meroctl/src/cli/group/join_group_context.rs new file mode 100644 index 0000000000..101ab6d4cb --- /dev/null +++ b/crates/meroctl/src/cli/group/join_group_context.rs @@ -0,0 +1,30 @@ +use calimero_server_primitives::admin::JoinGroupContextApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Join a context via group membership (no invitation needed)")] +pub struct JoinGroupContextCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(long, help = "The context ID to join")] + pub context_id: calimero_primitives::context::ContextId, +} + +impl JoinGroupContextCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = JoinGroupContextApiRequest { + context_id: self.context_id, + }; + + let client = environment.client()?; + let response = client.join_group_context(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/list.rs b/crates/meroctl/src/cli/group/list.rs new file mode 100644 index 0000000000..34df3f39d5 --- /dev/null +++ b/crates/meroctl/src/cli/group/list.rs @@ -0,0 +1,19 @@ +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Copy, Clone, Debug, Parser)] +#[command(about = "List all groups")] +pub struct ListCommand; + +impl ListCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.list_groups().await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/members.rs b/crates/meroctl/src/cli/group/members.rs new file mode 100644 index 0000000000..941fea3878 --- /dev/null +++ b/crates/meroctl/src/cli/group/members.rs @@ -0,0 +1,386 @@ +use calimero_primitives::context::{ContextId, GroupMemberRole}; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::{ + AddGroupMembersApiRequest, GroupMemberApiInput, RemoveGroupMembersApiRequest, + SetMemberCapabilitiesApiRequest, UpdateMemberRoleApiRequest, +}; +use clap::{Parser, Subcommand, ValueEnum}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, ValueEnum)] +pub enum MemberRoleArg { + Admin, + Member, +} + +impl From for GroupMemberRole { + fn from(arg: MemberRoleArg) -> Self { + match arg { + MemberRoleArg::Admin => GroupMemberRole::Admin, + MemberRoleArg::Member => GroupMemberRole::Member, + } + } +} + +#[derive(Debug, Parser)] +#[command(about = "Manage group members")] +pub struct MembersCommand { + #[command(subcommand)] + pub subcommand: MembersSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum MembersSubCommands { + #[command(alias = "ls", about = "List all members of a group")] + List(ListMembersCommand), + #[command(about = "Add a member to a group")] + Add(AddMembersCommand), + #[command(about = "Remove members from a group")] + Remove(RemoveMembersCommand), + #[command(about = "Update the role of a group member")] + SetRole(SetRoleCommand), + #[command( + alias = "set-caps", + about = "Set capabilities for a group member (admin-only)" + )] + SetCapabilities(SetCapabilitiesCommand), + #[command(alias = "get-caps", about = "Get capabilities of a group member")] + GetCapabilities(GetCapabilitiesCommand), + #[command(about = "Check if an identity can join a context in this group")] + CheckAccess(CheckAccessCommand), +} + +impl MembersCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + MembersSubCommands::List(cmd) => cmd.run(environment).await, + MembersSubCommands::Add(cmd) => cmd.run(environment).await, + MembersSubCommands::Remove(cmd) => cmd.run(environment).await, + MembersSubCommands::SetRole(cmd) => cmd.run(environment).await, + MembersSubCommands::SetCapabilities(cmd) => cmd.run(environment).await, + MembersSubCommands::GetCapabilities(cmd) => cmd.run(environment).await, + MembersSubCommands::CheckAccess(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "List all members of a group")] +pub struct ListMembersCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, +} + +impl ListMembersCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.list_group_members(&self.group_id).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Add a member to a group")] +pub struct AddMembersCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "IDENTITY", help = "Public key of the identity to add")] + pub identity: PublicKey, + + #[clap( + name = "ROLE", + value_enum, + default_value = "member", + help = "Role to assign to the new member" + )] + pub role: MemberRoleArg, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl AddMembersCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = AddGroupMembersApiRequest { + members: vec![GroupMemberApiInput { + identity: self.identity, + role: self.role.into(), + }], + requester: self.requester, + }; + + let client = environment.client()?; + let response = client.add_group_members(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Remove members from a group")] +pub struct RemoveMembersCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + name = "IDENTITIES", + required = true, + help = "Public keys of identities to remove (space-separated)" + )] + pub identities: Vec, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl RemoveMembersCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = RemoveGroupMembersApiRequest { + members: self.identities, + requester: self.requester, + }; + + let client = environment.client()?; + let response = client.remove_group_members(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Update the role of a group member")] +pub struct SetRoleCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + name = "IDENTITY", + help = "Public key of the member whose role to update" + )] + pub identity: PublicKey, + + #[clap(name = "ROLE", value_enum, help = "New role to assign")] + pub role: MemberRoleArg, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl SetRoleCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let identity_hex = hex::encode(self.identity.digest()); + + let request = UpdateMemberRoleApiRequest { + role: self.role.into(), + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .update_member_role(&self.group_id, &identity_hex, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Set capabilities for a group member")] +pub struct SetCapabilitiesCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "IDENTITY", help = "Public key of the member")] + pub identity: PublicKey, + + #[clap(long, help = "Allow member to create contexts in the group")] + pub can_create_context: bool, + + #[clap(long, help = "Allow member to invite others to the group")] + pub can_invite_members: bool, + + #[clap(long, help = "Allow member to join open contexts")] + pub can_join_open_contexts: bool, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl SetCapabilitiesCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let mut capabilities: u32 = 0; + if self.can_create_context { + capabilities |= 1 << 0; + } + if self.can_invite_members { + capabilities |= 1 << 1; + } + if self.can_join_open_contexts { + capabilities |= 1 << 2; + } + + let identity_hex = hex::encode(self.identity.digest()); + + let request = SetMemberCapabilitiesApiRequest { + capabilities, + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .set_member_capabilities(&self.group_id, &identity_hex, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Get capabilities of a group member")] +pub struct GetCapabilitiesCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "IDENTITY", help = "Public key of the member")] + pub identity: PublicKey, +} + +impl GetCapabilitiesCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let identity_hex = hex::encode(self.identity.digest()); + + let client = environment.client()?; + let response = client + .get_member_capabilities(&self.group_id, &identity_hex) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command( + about = "Diagnostic: check if an identity can join a context (role + capabilities + visibility + allowlist)" +)] +pub struct CheckAccessCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(name = "CONTEXT_ID", help = "The context ID (base58)")] + pub context_id: ContextId, + + #[clap(name = "IDENTITY", help = "Public key of the identity to check")] + pub identity: PublicKey, +} + +impl CheckAccessCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let identity_hex = hex::encode(self.identity.digest()); + let context_id_hex = hex::encode(AsRef::<[u8; 32]>::as_ref(&self.context_id)); + + let client = environment.client()?; + + let caps_response = client + .get_member_capabilities(&self.group_id, &identity_hex) + .await?; + let visibility_response = client + .get_context_visibility(&self.group_id, &context_id_hex) + .await?; + let allowlist_response = client + .get_context_allowlist(&self.group_id, &context_id_hex) + .await?; + let members_response = client.list_group_members(&self.group_id).await?; + + let caps = caps_response.data.capabilities; + let visibility = &visibility_response.data.mode; + let on_allowlist = allowlist_response.data.contains(&self.identity); + let role = members_response + .data + .iter() + .find(|m| m.identity == self.identity) + .map(|m| format!("{:?}", m.role).to_lowercase()) + .unwrap_or_else(|| "not a member".to_owned()); + let is_admin = members_response + .data + .iter() + .any(|m| m.identity == self.identity && m.role == GroupMemberRole::Admin); + + println!("Role: {role}"); + println!( + "CAN_CREATE_CONTEXT: {}", + if caps & (1 << 0) != 0 { + "true" + } else { + "false" + } + ); + println!( + "CAN_INVITE_MEMBERS: {}", + if caps & (1 << 1) != 0 { + "true" + } else { + "false" + } + ); + println!( + "CAN_JOIN_OPEN_CONTEXTS: {}", + if caps & (1 << 2) != 0 { + "true" + } else { + "false" + } + ); + println!(); + println!("Context visibility: {visibility}"); + println!( + "On allowlist: {}", + if on_allowlist { "YES" } else { "NO" } + ); + println!(); + + let verdict = if visibility == "open" { + if caps & (1 << 2) != 0 { + "CAN JOIN — open context and identity has CAN_JOIN_OPEN_CONTEXTS".to_owned() + } else { + "CANNOT JOIN — open context but identity lacks CAN_JOIN_OPEN_CONTEXTS".to_owned() + } + } else if is_admin || on_allowlist { + "CAN JOIN — context is restricted and identity is on the allowlist (or is an admin)" + .to_owned() + } else { + "CANNOT JOIN — context is restricted and identity is not on the allowlist".to_owned() + }; + + println!("Verdict: {verdict}"); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/settings.rs b/crates/meroctl/src/cli/group/settings.rs new file mode 100644 index 0000000000..44f264727b --- /dev/null +++ b/crates/meroctl/src/cli/group/settings.rs @@ -0,0 +1,189 @@ +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::{ + SetDefaultCapabilitiesApiRequest, SetDefaultVisibilityApiRequest, +}; +use clap::{Parser, Subcommand, ValueEnum}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, ValueEnum)] +pub enum VisibilityModeArg { + Open, + Restricted, +} + +#[derive(Debug, Parser)] +#[command(about = "Manage group-level default settings")] +pub struct SettingsCommand { + #[command(subcommand)] + pub subcommand: SettingsSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum SettingsSubCommands { + #[command(about = "Get current default settings for a group")] + Get(SettingsGetCommand), + #[command( + alias = "set-default-caps", + about = "Set default capabilities for new group members" + )] + SetDefaultCapabilities(SetDefaultCapabilitiesCommand), + #[command( + alias = "set-default-vis", + about = "Set default visibility mode for new contexts" + )] + SetDefaultVisibility(SetDefaultVisibilityCommand), +} + +impl SettingsCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + SettingsSubCommands::Get(cmd) => cmd.run(environment).await, + SettingsSubCommands::SetDefaultCapabilities(cmd) => cmd.run(environment).await, + SettingsSubCommands::SetDefaultVisibility(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Get current default settings for a group")] +pub struct SettingsGetCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, +} + +impl SettingsGetCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.get_group_info(&self.group_id).await?; + + let caps = response.data.default_capabilities; + let vis = &response.data.default_visibility; + + use comfy_table::{Cell, Color, Table}; + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Default Setting").fg(Color::Blue), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Default Visibility", vis.as_str()]); + let _ = table.add_row(vec![ + "CAN_CREATE_CONTEXT", + if caps & (1 << 0) != 0 { + "true" + } else { + "false" + }, + ]); + let _ = table.add_row(vec![ + "CAN_INVITE_MEMBERS", + if caps & (1 << 1) != 0 { + "true" + } else { + "false" + }, + ]); + let _ = table.add_row(vec![ + "CAN_JOIN_OPEN_CONTEXTS", + if caps & (1 << 2) != 0 { + "true" + } else { + "false" + }, + ]); + println!("{table}"); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Set default capabilities for new group members (admin-only)")] +pub struct SetDefaultCapabilitiesCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(long, help = "Allow new members to create contexts by default")] + pub can_create_context: bool, + + #[clap(long, help = "Allow new members to invite others by default")] + pub can_invite_members: bool, + + #[clap(long, help = "Allow new members to join open contexts by default")] + pub can_join_open_contexts: bool, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl SetDefaultCapabilitiesCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let mut capabilities: u32 = 0; + if self.can_create_context { + capabilities |= 1 << 0; + } + if self.can_invite_members { + capabilities |= 1 << 1; + } + if self.can_join_open_contexts { + capabilities |= 1 << 2; + } + + let request = SetDefaultCapabilitiesApiRequest { + default_capabilities: capabilities, + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .set_default_capabilities(&self.group_id, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Set default visibility mode for new contexts in the group (admin-only)")] +pub struct SetDefaultVisibilityCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(long, value_enum, help = "Default visibility: open or restricted")] + pub mode: VisibilityModeArg, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl SetDefaultVisibilityCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let mode_str = match self.mode { + VisibilityModeArg::Open => "open", + VisibilityModeArg::Restricted => "restricted", + }; + + let request = SetDefaultVisibilityApiRequest { + default_visibility: mode_str.to_owned(), + requester: self.requester, + }; + + let client = environment.client()?; + let response = client + .set_default_visibility(&self.group_id, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/signing_key.rs b/crates/meroctl/src/cli/group/signing_key.rs new file mode 100644 index 0000000000..6db933c6bf --- /dev/null +++ b/crates/meroctl/src/cli/group/signing_key.rs @@ -0,0 +1,56 @@ +use calimero_server_primitives::admin::RegisterGroupSigningKeyApiRequest; +use clap::{Parser, Subcommand}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Debug, Parser)] +#[command(about = "Manage signing keys for a group")] +pub struct SigningKeyCommand { + #[command(subcommand)] + pub subcommand: SigningKeySubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum SigningKeySubCommands { + #[command(about = "Register a signing key for a group admin")] + Register(RegisterSigningKeyCommand), +} + +impl SigningKeyCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + SigningKeySubCommands::Register(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Register a signing key for a group admin on this node")] +pub struct RegisterSigningKeyCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + name = "SIGNING_KEY", + help = "The hex-encoded private signing key to register" + )] + pub signing_key: String, +} + +impl RegisterSigningKeyCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = RegisterGroupSigningKeyApiRequest { + signing_key: self.signing_key, + }; + + let client = environment.client()?; + let response = client + .register_group_signing_key(&self.group_id, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/sync.rs b/crates/meroctl/src/cli/group/sync.rs new file mode 100644 index 0000000000..980515a256 --- /dev/null +++ b/crates/meroctl/src/cli/group/sync.rs @@ -0,0 +1,46 @@ +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::SyncGroupApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Sync a group from its contract state")] +pub struct SyncCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + long, + help = "Public key of the requester. Auto-resolved from node group identity if omitted" + )] + pub requester: Option, + + #[clap(long, help = "Optional protocol identifier")] + pub protocol: Option, + + #[clap(long, help = "Optional network/chain ID")] + pub network_id: Option, + + #[clap(long, help = "Optional contract ID")] + pub contract_id: Option, +} + +impl SyncCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = SyncGroupApiRequest { + requester: self.requester, + protocol: self.protocol, + network_id: self.network_id, + contract_id: self.contract_id, + }; + + let client = environment.client()?; + let response = client.sync_group(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/update.rs b/crates/meroctl/src/cli/group/update.rs new file mode 100644 index 0000000000..cc4f1fed3d --- /dev/null +++ b/crates/meroctl/src/cli/group/update.rs @@ -0,0 +1,60 @@ +use std::time::Duration; + +use calimero_primitives::context::UpgradePolicy; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::UpdateGroupSettingsApiRequest; +use clap::Parser; +use eyre::Result; + +use crate::cli::group::create::UpgradePolicyArg; +use crate::cli::Environment; + +#[derive(Clone, Debug, Parser)] +#[command(about = "Update group settings")] +pub struct UpdateCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, + + #[clap( + long, + value_enum, + default_value = "lazy-on-access", + help = "New upgrade policy" + )] + pub upgrade_policy: UpgradePolicyArg, + + #[clap(long, help = "Deadline in seconds for coordinated upgrade policy")] + pub deadline_secs: Option, +} + +impl UpdateCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let upgrade_policy = match self.upgrade_policy { + UpgradePolicyArg::Automatic => UpgradePolicy::Automatic, + UpgradePolicyArg::LazyOnAccess => UpgradePolicy::LazyOnAccess, + UpgradePolicyArg::Coordinated => UpgradePolicy::Coordinated { + deadline: self.deadline_secs.map(Duration::from_secs), + }, + }; + + let request = UpdateGroupSettingsApiRequest { + requester: self.requester, + upgrade_policy, + }; + + let client = environment.client()?; + let response = client + .update_group_settings(&self.group_id, request) + .await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/group/upgrade.rs b/crates/meroctl/src/cli/group/upgrade.rs new file mode 100644 index 0000000000..b0b2a00b87 --- /dev/null +++ b/crates/meroctl/src/cli/group/upgrade.rs @@ -0,0 +1,116 @@ +use calimero_primitives::application::ApplicationId; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::{RetryGroupUpgradeApiRequest, UpgradeGroupApiRequest}; +use clap::{Parser, Subcommand}; +use eyre::Result; + +use crate::cli::Environment; + +#[derive(Debug, Parser)] +#[command(about = "Manage group upgrades")] +pub struct UpgradeCommand { + #[command(subcommand)] + pub subcommand: UpgradeSubCommands, +} + +#[derive(Debug, Subcommand)] +pub enum UpgradeSubCommands { + #[command(about = "Trigger an upgrade for a group")] + Trigger(TriggerUpgradeCommand), + #[command(about = "Get the current upgrade status of a group")] + Status(UpgradeStatusCommand), + #[command(about = "Retry a failed group upgrade")] + Retry(RetryUpgradeCommand), +} + +impl UpgradeCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + match self.subcommand { + UpgradeSubCommands::Trigger(cmd) => cmd.run(environment).await, + UpgradeSubCommands::Status(cmd) => cmd.run(environment).await, + UpgradeSubCommands::Retry(cmd) => cmd.run(environment).await, + } + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Trigger an upgrade for a group to a new application version")] +pub struct TriggerUpgradeCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap(long, help = "Target application ID to upgrade to")] + pub target_application_id: ApplicationId, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, + + #[clap(long, help = "Optional migration method name to call on each context")] + pub migrate_method: Option, +} + +impl TriggerUpgradeCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = UpgradeGroupApiRequest { + target_application_id: self.target_application_id, + requester: self.requester, + migrate_method: self.migrate_method, + }; + + let client = environment.client()?; + let response = client.upgrade_group(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Get the current upgrade status of a group")] +pub struct UpgradeStatusCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, +} + +impl UpgradeStatusCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let client = environment.client()?; + let response = client.get_group_upgrade_status(&self.group_id).await?; + + environment.output.write(&response); + + Ok(()) + } +} + +#[derive(Clone, Debug, Parser)] +#[command(about = "Retry a failed group upgrade")] +pub struct RetryUpgradeCommand { + #[clap(name = "GROUP_ID", help = "The hex-encoded group ID")] + pub group_id: String, + + #[clap( + long, + help = "Public key of the requester (group admin). Auto-resolved from node group identity if omitted" + )] + pub requester: Option, +} + +impl RetryUpgradeCommand { + pub async fn run(self, environment: &mut Environment) -> Result<()> { + let request = RetryGroupUpgradeApiRequest { + requester: self.requester, + }; + + let client = environment.client()?; + let response = client.retry_group_upgrade(&self.group_id, request).await?; + + environment.output.write(&response); + + Ok(()) + } +} diff --git a/crates/meroctl/src/cli/node.rs b/crates/meroctl/src/cli/node.rs index 7ff5c7b94d..2b10d4bc5b 100644 --- a/crates/meroctl/src/cli/node.rs +++ b/crates/meroctl/src/cli/node.rs @@ -1,5 +1,5 @@ use calimero_client::storage::JwtToken; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use clap::{Parser, Subcommand}; use comfy_table::Table; use const_format::concatcp; @@ -94,10 +94,19 @@ pub enum NodeCommand { /// List all configured nodes #[command(alias = "ls")] List, + + /// Display the node's group identity public key + #[command(alias = "id")] + Identity, } impl NodeCommand { - pub async fn run(self, environment: &Environment) -> Result<()> { + pub async fn run( + self, + environment: &Environment, + node_arg: Option<&str>, + home: &Utf8Path, + ) -> Result<()> { let mut config = Config::load().await?; match self { @@ -193,6 +202,39 @@ impl NodeCommand { println!("{table}"); return Ok(()); } + NodeCommand::Identity => { + let node_config = if let Some(name) = node_arg { + load_config(home, name).await? + } else { + let active = config.active_node.as_ref().ok_or_else(|| { + eyre::eyre!( + "no node specified; use `--node ` or `meroctl node use `" + ) + })?; + + let conn = config + .nodes + .get(active) + .ok_or_else(|| eyre::eyre!("active node '{active}' not found in config"))?; + + let path = match conn { + NodeConnection::Local { path, .. } => path, + NodeConnection::Remote { .. } => { + bail!("identity command is only supported for local nodes"); + } + }; + + load_config(path, active).await? + }; + + match node_config.identity.group { + Some(gi) => println!("{}", gi.public_key), + None => { + bail!("no group identity configured (node may need re-initialization)") + } + } + return Ok(()); + } } config.save().await diff --git a/crates/meroctl/src/output.rs b/crates/meroctl/src/output.rs index 0b56f6f4b9..b7c2eeb1c3 100644 --- a/crates/meroctl/src/output.rs +++ b/crates/meroctl/src/output.rs @@ -4,6 +4,7 @@ pub mod applications; pub mod blobs; pub mod common; pub mod contexts; +pub mod groups; pub mod proposals; // Re-export common types diff --git a/crates/meroctl/src/output/groups.rs b/crates/meroctl/src/output/groups.rs new file mode 100644 index 0000000000..ec2fd51c4a --- /dev/null +++ b/crates/meroctl/src/output/groups.rs @@ -0,0 +1,398 @@ +use calimero_server_primitives::admin::{ + AddGroupMembersApiResponse, CreateGroupApiResponse, CreateGroupInvitationApiResponse, + DeleteGroupApiResponse, DetachContextFromGroupApiResponse, GetContextAllowlistApiResponse, + GetContextVisibilityApiResponse, GetGroupUpgradeStatusApiResponse, + GetMemberCapabilitiesApiResponse, GroupInfoApiResponse, JoinGroupApiResponse, + JoinGroupContextApiResponse, ListAllGroupsApiResponse, ListGroupContextsApiResponse, + ListGroupMembersApiResponse, ManageContextAllowlistApiResponse, + RegisterGroupSigningKeyApiResponse, RemoveGroupMembersApiResponse, + SetContextVisibilityApiResponse, SetDefaultCapabilitiesApiResponse, + SetDefaultVisibilityApiResponse, SetMemberCapabilitiesApiResponse, SyncGroupApiResponse, + UpdateGroupSettingsApiResponse, UpdateMemberRoleApiResponse, UpgradeGroupApiResponse, +}; +use color_eyre::owo_colors::OwoColorize; +use comfy_table::{Cell, Color, Table}; + +use super::Report; + +impl Report for ListAllGroupsApiResponse { + fn report(&self) { + if self.data.is_empty() { + println!("No groups found"); + } else { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Group ID").fg(Color::Blue), + Cell::new("App Key").fg(Color::Blue), + Cell::new("Application ID").fg(Color::Blue), + Cell::new("Upgrade Policy").fg(Color::Blue), + Cell::new("Created At").fg(Color::Blue), + ]); + for group in &self.data { + let _ = table.add_row(vec![ + group.group_id.clone(), + group.app_key.clone(), + group.target_application_id.to_string(), + format!("{:?}", group.upgrade_policy), + group.created_at.to_string(), + ]); + } + println!("{table}"); + } + } +} + +impl Report for CreateGroupApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Group Created").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Group ID", &self.data.group_id]); + println!("{table}"); + } +} + +impl Report for GroupInfoApiResponse { + fn report(&self) { + let d = &self.data; + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Field").fg(Color::Blue), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Group ID", &d.group_id]); + let _ = table.add_row(vec!["App Key", &d.app_key]); + let _ = table.add_row(vec!["Application ID", &d.target_application_id.to_string()]); + let _ = table.add_row(vec!["Upgrade Policy", &format!("{:?}", d.upgrade_policy)]); + let _ = table.add_row(vec!["Members", &d.member_count.to_string()]); + let _ = table.add_row(vec!["Contexts", &d.context_count.to_string()]); + if let Some(ref upgrade) = d.active_upgrade { + let _ = table.add_row(vec!["Active Upgrade Status", &upgrade.status]); + } + println!("{table}"); + } +} + +impl Report for DeleteGroupApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Group Deleted").fg(Color::Green)]); + let _ = table.add_row(vec![format!( + "Successfully deleted group (deleted: {})", + self.data.is_deleted + )]); + println!("{table}"); + } +} + +impl Report for UpdateGroupSettingsApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Group Settings Updated").fg(Color::Green)]); + let _ = table.add_row(vec!["Successfully updated group settings"]); + println!("{table}"); + } +} + +impl Report for ListGroupMembersApiResponse { + fn report(&self) { + if self.data.is_empty() { + println!("No members found in group"); + } else { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Identity").fg(Color::Blue), + Cell::new("Role").fg(Color::Blue), + ]); + for member in &self.data { + let _ = table.add_row(vec![ + member.identity.to_string(), + format!("{:?}", member.role), + ]); + } + println!("{table}"); + } + } +} + +impl Report for AddGroupMembersApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Members Added").fg(Color::Green)]); + let _ = table.add_row(vec!["Successfully added members to group"]); + println!("{table}"); + } +} + +impl Report for RemoveGroupMembersApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Members Removed").fg(Color::Green)]); + let _ = table.add_row(vec!["Successfully removed members from group"]); + println!("{table}"); + } +} + +impl Report for UpdateMemberRoleApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Member Role Updated").fg(Color::Green)]); + let _ = table.add_row(vec!["Successfully updated member role"]); + println!("{table}"); + } +} + +impl Report for ListGroupContextsApiResponse { + fn report(&self) { + if self.data.is_empty() { + println!("No contexts found in group"); + } else { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Context ID").fg(Color::Blue), + Cell::new("Alias").fg(Color::Blue), + ]); + for entry in &self.data { + let alias = entry.alias.as_deref().unwrap_or("-"); + let _ = table.add_row(vec![entry.context_id.clone(), alias.to_owned()]); + } + println!("{table}"); + } + } +} + +impl Report for DetachContextFromGroupApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Context Detached").fg(Color::Green)]); + let _ = table.add_row(vec!["Successfully detached context from group"]); + println!("{table}"); + } +} + +impl Report for CreateGroupInvitationApiResponse { + fn report(&self) { + println!("{}", "Group Invitation Created Successfully".green()); + println!(); + let pretty = serde_json::to_string_pretty(&self.data.invitation) + .unwrap_or_else(|_| format!("{:?}", self.data.invitation)); + println!("{pretty}"); + println!(); + let compact = serde_json::to_string(&self.data.invitation).unwrap_or_default(); + println!("To join, run from another node:"); + println!(" meroctl --node group join '{compact}'"); + } +} + +impl Report for JoinGroupApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Group Joined").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Group ID", &self.data.group_id]); + let _ = table.add_row(vec![ + "Member Identity", + &self.data.member_identity.to_string(), + ]); + println!("{table}"); + } +} + +impl Report for RegisterGroupSigningKeyApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Signing Key Registered").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Public Key", &self.data.public_key.to_string()]); + println!("{table}"); + } +} + +impl Report for UpgradeGroupApiResponse { + fn report(&self) { + let d = &self.data; + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Group Upgrade").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Group ID", &d.group_id]); + let _ = table.add_row(vec!["Status", &d.status]); + if let Some(total) = d.total { + let _ = table.add_row(vec!["Total", &total.to_string()]); + } + if let Some(completed) = d.completed { + let _ = table.add_row(vec!["Completed", &completed.to_string()]); + } + if let Some(failed) = d.failed { + let _ = table.add_row(vec!["Failed", &failed.to_string()]); + } + println!("{table}"); + } +} + +impl Report for GetGroupUpgradeStatusApiResponse { + fn report(&self) { + match &self.data { + None => println!("No active upgrade in progress"), + Some(upgrade) => { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Upgrade Status").fg(Color::Blue), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["From Version", &upgrade.from_version]); + let _ = table.add_row(vec!["To Version", &upgrade.to_version]); + let _ = table.add_row(vec!["Status", &upgrade.status]); + let _ = table.add_row(vec!["Initiated By", &upgrade.initiated_by.to_string()]); + let _ = table.add_row(vec!["Initiated At", &upgrade.initiated_at.to_string()]); + if let Some(total) = upgrade.total { + let _ = table.add_row(vec!["Total", &total.to_string()]); + } + if let Some(completed) = upgrade.completed { + let _ = table.add_row(vec!["Completed", &completed.to_string()]); + } + if let Some(failed) = upgrade.failed { + let _ = table.add_row(vec!["Failed", &failed.to_string()]); + } + if let Some(completed_at) = upgrade.completed_at { + let _ = table.add_row(vec!["Completed At", &completed_at.to_string()]); + } + println!("{table}"); + } + } + } +} + +impl Report for JoinGroupContextApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Joined Context via Group").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Context ID", &self.data.context_id.to_string()]); + let _ = table.add_row(vec![ + "Member Public Key", + &self.data.member_public_key.to_string(), + ]); + println!("{table}"); + } +} + +impl Report for SyncGroupApiResponse { + fn report(&self) { + let d = &self.data; + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Group Synced").fg(Color::Green), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Group ID", &d.group_id]); + let _ = table.add_row(vec!["App Key", &d.app_key]); + let _ = table.add_row(vec!["Application ID", &d.target_application_id.to_string()]); + let _ = table.add_row(vec!["Members", &d.member_count.to_string()]); + let _ = table.add_row(vec!["Contexts", &d.context_count.to_string()]); + println!("{table}"); + } +} + +// ---- Group Permissions ---- + +impl Report for SetMemberCapabilitiesApiResponse { + fn report(&self) { + println!("{}", "Member capabilities updated successfully".green()); + } +} + +impl Report for GetMemberCapabilitiesApiResponse { + fn report(&self) { + let caps = self.data.capabilities; + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Capability").fg(Color::Blue), + Cell::new("Enabled").fg(Color::Blue), + ]); + let _ = table.add_row(vec![ + "CAN_CREATE_CONTEXT".to_owned(), + if caps & (1 << 0) != 0 { "yes" } else { "no" }.to_owned(), + ]); + let _ = table.add_row(vec![ + "CAN_INVITE_MEMBERS".to_owned(), + if caps & (1 << 1) != 0 { "yes" } else { "no" }.to_owned(), + ]); + let _ = table.add_row(vec![ + "CAN_JOIN_OPEN_CONTEXTS".to_owned(), + if caps & (1 << 2) != 0 { "yes" } else { "no" }.to_owned(), + ]); + let _ = table.add_row(vec![ + "Raw value".to_owned(), + format!("{caps} (0b{caps:03b})"), + ]); + println!("{table}"); + } +} + +impl Report for SetContextVisibilityApiResponse { + fn report(&self) { + println!("{}", "Context visibility updated successfully".green()); + } +} + +impl Report for GetContextVisibilityApiResponse { + fn report(&self) { + let mut table = Table::new(); + let _ = table.set_header(vec![ + Cell::new("Field").fg(Color::Blue), + Cell::new("Value").fg(Color::Blue), + ]); + let _ = table.add_row(vec!["Mode", &self.data.mode]); + let _ = table.add_row(vec!["Creator", &self.data.creator.to_string()]); + println!("{table}"); + } +} + +impl Report for ManageContextAllowlistApiResponse { + fn report(&self) { + println!("{}", "Context allowlist updated successfully".green()); + } +} + +impl Report for GetContextAllowlistApiResponse { + fn report(&self) { + if self.data.is_empty() { + println!("Allowlist is empty"); + } else { + let mut table = Table::new(); + let _ = table.set_header(vec![Cell::new("Allowed Member").fg(Color::Blue)]); + for member in &self.data { + let _ = table.add_row(vec![member.to_string()]); + } + println!("{table}"); + } + } +} + +impl Report for SetDefaultCapabilitiesApiResponse { + fn report(&self) { + println!( + "{}", + "Default member capabilities updated successfully".green() + ); + } +} + +impl Report for SetDefaultVisibilityApiResponse { + fn report(&self) { + println!( + "{}", + "Default context visibility updated successfully".green() + ); + } +} diff --git a/crates/merod/Cargo.toml b/crates/merod/Cargo.toml index b07c3bea71..d5242b47db 100644 --- a/crates/merod/Cargo.toml +++ b/crates/merod/Cargo.toml @@ -11,6 +11,7 @@ publish = false [dependencies] base64.workspace = true +bs58.workspace = true camino = { workspace = true, features = ["serde1"] } clap = { workspace = true, features = ["env", "derive"] } color-eyre.workspace = true diff --git a/crates/merod/src/cli/init.rs b/crates/merod/src/cli/init.rs index 0f15d630be..f09a72f014 100644 --- a/crates/merod/src/cli/init.rs +++ b/crates/merod/src/cli/init.rs @@ -1,6 +1,6 @@ use calimero_config::{ - BlobStoreConfig, ConfigFile, DataStoreConfig as StoreConfigFile, NetworkConfig, NodeMode, - ServerConfig, SyncConfig, + BlobStoreConfig, ConfigFile, DataStoreConfig as StoreConfigFile, GroupIdentityConfig, + IdentityConfig, NetworkConfig, NodeMode, ServerConfig, SyncConfig, }; use calimero_context::config::ContextConfig; use calimero_context_config::client::config::{ @@ -233,6 +233,20 @@ impl InitCommand { let identity = Keypair::generate_ed25519(); info!("Generated identity: {:?}", identity.public().to_peer_id()); + let group_sk = ed25519_consensus::SigningKey::new(rand::thread_rng()); + let group_vk = group_sk.verification_key(); + let group_identity = GroupIdentityConfig { + public_key: format!( + "ed25519:{}", + bs58::encode(group_vk.as_bytes()).into_string() + ), + secret_key: format!( + "ed25519:{}", + bs58::encode(group_sk.as_bytes()).into_string() + ), + }; + info!("Generated group identity: {}", group_identity.public_key); + let mut listen: Vec = vec![]; for host in self.swarm_host { @@ -346,7 +360,10 @@ impl InitCommand { ); let config = ConfigFile::new( - identity, + IdentityConfig { + keypair: identity, + group: Some(group_identity), + }, self.mode, NetworkConfig::new( SwarmConfig::new(listen), diff --git a/crates/merod/src/cli/kms.rs b/crates/merod/src/cli/kms.rs index 02ef15dd35..5a0d978613 100644 --- a/crates/merod/src/cli/kms.rs +++ b/crates/merod/src/cli/kms.rs @@ -68,8 +68,8 @@ impl KmsProbeCommand { "Failed to resolve tee.kms.phala.attestation policy (including external policy_json_path)", )?; - let peer_id = config.identity.public().to_peer_id().to_base58(); - let result = kms::probe_storage_key(&kms_config, &peer_id, &config.identity).await; + let peer_id = config.identity.keypair.public().to_peer_id().to_base58(); + let result = kms::probe_storage_key(&kms_config, &peer_id, &config.identity.keypair).await; print_result(json, &result)?; if result.ok { diff --git a/crates/merod/src/cli/run.rs b/crates/merod/src/cli/run.rs index c01165997a..da1d9c5a64 100644 --- a/crates/merod/src/cli/run.rs +++ b/crates/merod/src/cli/run.rs @@ -57,14 +57,14 @@ impl RunCommand { // Fetch storage encryption key from KMS if configured let encryption_key = if let Some(ref tee_config) = config.tee { - let peer_id = config.identity.public().to_peer_id().to_base58(); + let peer_id = config.identity.keypair.public().to_peer_id().to_base58(); info!("TEE configured, fetching storage key for peer {}", peer_id); let policy = crate::kms_policy::resolve_policy().await?; let key = kms::fetch_storage_key( &tee_config.kms, &peer_id, - &config.identity, + &config.identity.keypair, policy.as_ref(), ) .await @@ -122,7 +122,7 @@ impl RunCommand { } let server_config = ServerConfig::with_auth( server_source.listen, - config.identity.clone(), + config.identity.keypair.clone(), server_source.admin, server_source.jsonrpc, server_source.websocket, @@ -143,9 +143,10 @@ impl RunCommand { start(NodeConfig { home: path.clone(), - identity: config.identity.clone(), + identity: config.identity.keypair.clone(), + group_identity: config.identity.group.clone(), network: NetworkConfig::new( - config.identity.clone(), + config.identity.keypair.clone(), network.swarm, network.bootstrap, network.discovery, diff --git a/crates/node/primitives/src/client.rs b/crates/node/primitives/src/client.rs index 55a0e6e4de..297637c694 100644 --- a/crates/node/primitives/src/client.rs +++ b/crates/node/primitives/src/client.rs @@ -18,7 +18,7 @@ use libp2p::gossipsub::{IdentTopic, TopicHash}; use libp2p::PeerId; use rand::Rng; use tokio::sync::{broadcast, mpsc}; -use tracing::info; +use tracing::{debug, info, warn}; use calimero_network_primitives::specialized_node_invite::SpecializedNodeType; @@ -84,6 +84,20 @@ impl NodeClient { Ok(()) } + pub async fn subscribe_group(&self, group_id: [u8; 32]) -> eyre::Result<()> { + let topic = IdentTopic::new(format!("group/{}", hex::encode(group_id))); + let _ignored = self.network_client.subscribe(topic).await?; + info!(?group_id, "Subscribed to group topic"); + Ok(()) + } + + pub async fn unsubscribe_group(&self, group_id: [u8; 32]) -> eyre::Result<()> { + let topic = IdentTopic::new(format!("group/{}", hex::encode(group_id))); + let _ignored = self.network_client.unsubscribe(topic).await?; + info!(?group_id, "Unsubscribed from group topic"); + Ok(()) + } + pub async fn get_peers_count(&self, context: Option<&ContextId>) -> usize { let Some(context) = context else { return self.network_client.peer_count().await; @@ -170,6 +184,36 @@ impl NodeClient { Ok(()) } + pub async fn broadcast_group_mutation( + &self, + group_id: [u8; 32], + mutation_kind: crate::sync::GroupMutationKind, + ) -> eyre::Result<()> { + let topic_str = format!("group/{}", hex::encode(group_id)); + let topic = TopicHash::from_raw(topic_str); + + let peers = self.network_client.mesh_peer_count(topic.clone()).await; + if peers == 0 { + debug!( + ?mutation_kind, + "no peers on group topic, skipping broadcast" + ); + return Ok(()); + } + + let payload = BroadcastMessage::GroupMutationNotification { + group_id, + mutation_kind, + }; + let payload_bytes = borsh::to_vec(&payload)?; + + if let Err(err) = self.network_client.publish(topic, payload_bytes).await { + warn!(?group_id, %err, "failed to publish group mutation notification"); + } + + Ok(()) + } + /// Broadcast a specialized node invite discovery to the global invite topic. /// /// This broadcasts a discovery message and registers a pending invite so that diff --git a/crates/node/primitives/src/lib.rs b/crates/node/primitives/src/lib.rs index 5e3e2df118..f59ab566ad 100644 --- a/crates/node/primitives/src/lib.rs +++ b/crates/node/primitives/src/lib.rs @@ -17,3 +17,11 @@ pub enum NodeMode { /// Read-only mode - disables JSON-RPC execution, used for TEE observer nodes ReadOnly, } + +/// Ed25519 keypair used as the node's group identity for signing group +/// contract operations, decoupled from the NEAR signer key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupIdentityConfig { + pub public_key: String, + pub secret_key: String, +} diff --git a/crates/node/primitives/src/sync.rs b/crates/node/primitives/src/sync.rs index 81f285292e..f42e48f0cb 100644 --- a/crates/node/primitives/src/sync.rs +++ b/crates/node/primitives/src/sync.rs @@ -85,9 +85,9 @@ pub use wire::{InitPayload, MessagePayload, StreamMessage, MAX_TREE_REQUEST_DEPT // Snapshot types pub use snapshot::{ - check_snapshot_safety, BroadcastMessage, SnapshotBoundaryRequest, SnapshotBoundaryResponse, - SnapshotComplete, SnapshotCursor, SnapshotEntity, SnapshotEntityPage, SnapshotError, - SnapshotPage, SnapshotRequest, SnapshotStreamRequest, SnapshotVerifyResult, + check_snapshot_safety, BroadcastMessage, GroupMutationKind, SnapshotBoundaryRequest, + SnapshotBoundaryResponse, SnapshotComplete, SnapshotCursor, SnapshotEntity, SnapshotEntityPage, + SnapshotError, SnapshotPage, SnapshotRequest, SnapshotStreamRequest, SnapshotVerifyResult, DEFAULT_SNAPSHOT_PAGE_SIZE, MAX_COMPRESSED_PAYLOAD_SIZE, MAX_DAG_HEADS, MAX_ENTITIES_PER_PAGE, MAX_ENTITY_DATA_SIZE, MAX_SNAPSHOT_PAGES, MAX_SNAPSHOT_PAGE_SIZE, }; diff --git a/crates/node/primitives/src/sync/snapshot.rs b/crates/node/primitives/src/sync/snapshot.rs index c05a1d88b6..028546d7aa 100644 --- a/crates/node/primitives/src/sync/snapshot.rs +++ b/crates/node/primitives/src/sync/snapshot.rs @@ -634,6 +634,62 @@ pub enum BroadcastMessage<'a> { /// The nonce from the original discovery request nonce: [u8; 32], }, + + /// Notification that a group mutation occurred. + /// Receiving nodes should re-sync the group state from the contract. + GroupMutationNotification { + group_id: [u8; 32], + mutation_kind: GroupMutationKind, + }, +} + +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub enum GroupMutationKind { + MembersAdded, + MembersRemoved, + Upgraded, + Deleted, + ContextDetached, + SettingsUpdated, + MemberRoleUpdated, + VisibilityUpdated, + ContextAttached, + ContextAliasSet { + context_id: [u8; 32], + alias: String, + }, + MemberCapabilitySet { + member: [u8; 32], + capabilities: u32, + }, + DefaultCapabilitiesSet { + capabilities: u32, + }, + ContextVisibilitySet { + context_id: [u8; 32], + /// 0 = Open, 1 = Restricted + mode: u8, + creator: [u8; 32], + }, + DefaultVisibilitySet { + /// 0 = Open, 1 = Restricted + mode: u8, + }, + ContextAllowlistSet { + context_id: [u8; 32], + /// Full replacement list — receiver clears then inserts + members: Vec<[u8; 32]>, + }, + MemberAliasSet { + member: [u8; 32], + alias: String, + }, + GroupAliasSet { + alias: String, + }, + ContextRegistered { + context_id: [u8; 32], + }, } // Wire protocol types (StreamMessage, InitPayload, MessagePayload) are in wire.rs diff --git a/crates/node/src/handlers/network_event.rs b/crates/node/src/handlers/network_event.rs index 140e632902..337a893758 100644 --- a/crates/node/src/handlers/network_event.rs +++ b/crates/node/src/handlers/network_event.rs @@ -30,7 +30,87 @@ impl Handler for NodeManager { } NetworkEvent::Subscribed { peer_id, topic } => { - let Ok(context_id): Result = topic.as_str().parse() else { + let topic_str = topic.as_str(); + + // Check for group topic: "group/" + if let Some(hex) = topic_str.strip_prefix("group/") { + let mut bytes = [0u8; 32]; + if hex::decode_to_slice(hex, &mut bytes).is_ok() { + info!(%peer_id, group_id=%hex, "Peer subscribed to group topic, triggering sync"); + let context_client = self.clients.context.clone(); + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::SyncGroupRequest; + + let group_id = ContextGroupId::from(bytes); + if let Err(err) = context_client + .sync_group(SyncGroupRequest { + group_id, + requester: None, + protocol: None, + network_id: None, + contract_id: None, + }) + .await + { + warn!( + ?err, + "Failed to auto-sync group after peer subscription" + ); + } + } + .into_actor(self), + ); + + let context_client_alias = self.clients.context.clone(); + let _ignored_alias = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::BroadcastGroupAliasesRequest; + + let group_id = ContextGroupId::from(bytes); + if let Err(err) = context_client_alias + .broadcast_group_aliases(BroadcastGroupAliasesRequest { + group_id, + }) + .await + { + warn!( + ?err, + "Failed to re-broadcast group aliases after peer subscription" + ); + } + } + .into_actor(self), + ); + + let context_client_local_state = self.clients.context.clone(); + let _ignored_local_state = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::BroadcastGroupLocalStateRequest; + + let group_id = ContextGroupId::from(bytes); + if let Err(err) = context_client_local_state + .broadcast_group_local_state(BroadcastGroupLocalStateRequest { + group_id, + }) + .await + { + warn!( + ?err, + "Failed to re-broadcast group local state after peer subscription" + ); + } + } + .into_actor(self), + ); + } + return; + } + + let Ok(context_id): Result = topic_str.parse() else { return; }; @@ -284,6 +364,303 @@ impl Handler for NodeManager { let pending_invites = self.state.pending_specialized_node_invites.clone(); specialized_node_invite::handle_join_confirmation(&pending_invites, nonce); } + BroadcastMessage::GroupMutationNotification { + group_id, + mutation_kind, + } => { + info!( + ?group_id, + ?mutation_kind, + %source, + "Received group mutation notification" + ); + + let context_client = self.clients.context.clone(); + + match mutation_kind { + calimero_node_primitives::sync::GroupMutationKind::ContextAliasSet { + context_id, + alias, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreContextAliasRequest; + use calimero_primitives::context::ContextId; + + let group_id = ContextGroupId::from(group_id); + let context_id = ContextId::from(context_id); + if let Err(err) = context_client + .store_context_alias(StoreContextAliasRequest { + group_id, + context_id, + alias, + }) + .await + { + warn!( + ?err, + "Failed to store context alias from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::MemberCapabilitySet { + member, + capabilities, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreMemberCapabilityRequest; + use calimero_primitives::identity::PublicKey; + + let group_id = ContextGroupId::from(group_id); + if let Err(err) = context_client + .store_member_capability(StoreMemberCapabilityRequest { + group_id, + member: PublicKey::from(member), + capabilities, + }) + .await + { + warn!( + ?err, + "Failed to store member capability from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::DefaultCapabilitiesSet { + capabilities, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreDefaultCapabilitiesRequest; + + let group_id = ContextGroupId::from(group_id); + if let Err(err) = context_client + .store_default_capabilities( + StoreDefaultCapabilitiesRequest { + group_id, + capabilities, + }, + ) + .await + { + warn!( + ?err, + "Failed to store default capabilities from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::ContextVisibilitySet { + context_id, + mode, + creator, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreContextVisibilityRequest; + use calimero_primitives::context::ContextId; + use calimero_primitives::identity::PublicKey; + + let group_id = ContextGroupId::from(group_id); + let context_id = ContextId::from(context_id); + if let Err(err) = context_client + .store_context_visibility( + StoreContextVisibilityRequest { + group_id, + context_id, + mode, + creator: PublicKey::from(creator), + }, + ) + .await + { + warn!( + ?err, + "Failed to store context visibility from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::DefaultVisibilitySet { + mode, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreDefaultVisibilityRequest; + + let group_id = ContextGroupId::from(group_id); + if let Err(err) = context_client + .store_default_visibility( + StoreDefaultVisibilityRequest { group_id, mode }, + ) + .await + { + warn!( + ?err, + "Failed to store default visibility from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::ContextAllowlistSet { + context_id, + members, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreContextAllowlistRequest; + use calimero_primitives::context::ContextId; + use calimero_primitives::identity::PublicKey; + + let group_id = ContextGroupId::from(group_id); + let context_id = ContextId::from(context_id); + let members: Vec = + members.into_iter().map(PublicKey::from).collect(); + if let Err(err) = context_client + .store_context_allowlist(StoreContextAllowlistRequest { + group_id, + context_id, + members, + }) + .await + { + warn!( + ?err, + "Failed to store context allowlist from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::MemberAliasSet { + member, + alias, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreMemberAliasRequest; + use calimero_primitives::identity::PublicKey; + + let group_id = ContextGroupId::from(group_id); + if let Err(err) = context_client + .store_member_alias(StoreMemberAliasRequest { + group_id, + member: PublicKey::from(member), + alias, + }) + .await + { + warn!( + ?err, + "Failed to store member alias from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::GroupAliasSet { + alias, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreGroupAliasRequest; + + let group_id = ContextGroupId::from(group_id); + if let Err(err) = context_client + .store_group_alias(StoreGroupAliasRequest { + group_id, + alias, + }) + .await + { + warn!( + ?err, + "Failed to store group alias from gossip" + ); + } + } + .into_actor(self), + ); + } + calimero_node_primitives::sync::GroupMutationKind::ContextRegistered { + context_id, + } => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::StoreGroupContextRequest; + use calimero_primitives::context::ContextId; + + let group_id = ContextGroupId::from(group_id); + let context_id = ContextId::from(context_id); + if let Err(err) = context_client + .store_group_context(StoreGroupContextRequest { + group_id, + context_id, + }) + .await + { + warn!( + ?err, + "Failed to store group context from gossip" + ); + } + } + .into_actor(self), + ); + } + _ => { + let _ignored = ctx.spawn( + async move { + use calimero_context_config::types::ContextGroupId; + use calimero_context_primitives::group::SyncGroupRequest; + + let group_id = ContextGroupId::from(group_id); + + if let Err(err) = context_client + .sync_group(SyncGroupRequest { + group_id, + requester: None, + protocol: None, + network_id: None, + contract_id: None, + }) + .await + { + warn!( + ?err, + "Failed to auto-sync group after mutation notification" + ); + } + } + .into_actor(self), + ); + } + } + } _ => { // Future message types - log and ignore debug!(?message, "Received unknown broadcast message type"); diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 1727ffe31f..4a4eb8d786 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -471,6 +471,36 @@ impl Actor for NodeManager { .into_actor(self), ); + // Subscribe to all group topics + let node_client = self.clients.node.clone(); + let context_client = self.clients.context.clone(); + + let _handle = ctx.spawn( + async move { + match context_client + .list_all_groups(calimero_context_primitives::group::ListAllGroupsRequest { + offset: 0, + limit: usize::MAX, + }) + .await + { + Ok(groups) => { + for group in groups { + if let Err(err) = + node_client.subscribe_group(group.group_id.to_bytes()).await + { + error!(?group.group_id, %err, "Failed to subscribe to group topic"); + } + } + } + Err(err) => { + error!(%err, "Failed to list groups for startup subscription"); + } + } + } + .into_actor(self), + ); + // Periodic blob cache eviction (every 5 minutes) let _handle = ctx.run_interval( Duration::from_secs(constants::OLD_BLOBS_EVICTION_FREQUENCY_S), diff --git a/crates/node/src/run.rs b/crates/node/src/run.rs index df93cefb55..c98970e86a 100644 --- a/crates/node/src/run.rs +++ b/crates/node/src/run.rs @@ -54,6 +54,7 @@ pub struct SpecializedNodeConfig { pub struct NodeConfig { pub home: Utf8PathBuf, pub identity: Keypair, + pub group_identity: Option, pub network: NetworkConfig, pub sync: SyncConfig, pub datastore: StoreConfig, @@ -157,6 +158,7 @@ pub async fn start(config: NodeConfig) -> eyre::Result<()> { node_client.clone(), context_client.clone(), config.context.client.clone(), + config.group_identity.clone(), Some(&mut registry), ); diff --git a/crates/primitives/src/context.rs b/crates/primitives/src/context.rs index 87e9127e31..be894d37d9 100644 --- a/crates/primitives/src/context.rs +++ b/crates/primitives/src/context.rs @@ -4,6 +4,7 @@ use core::fmt; use core::ops::Deref; use core::str::FromStr; +use core::time::Duration; use std::borrow::Cow; use std::io; @@ -310,6 +311,230 @@ pub struct ContextConfigParams<'a> { pub members_revision: u64, } +/// Controls how application upgrades propagate across contexts in a group. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub enum UpgradePolicy { + /// Upgrade all contexts immediately when the group target changes. + Automatic, + /// Upgrade each context transparently on its next execution. + #[default] + LazyOnAccess, + /// Upgrade all contexts with an optional deadline for completion. + Coordinated { deadline: Option }, +} + +#[cfg(feature = "borsh")] +const _: () = { + use borsh::{BorshDeserialize, BorshSerialize}; + use std::io::{Read, Write}; + + impl BorshSerialize for UpgradePolicy { + fn serialize(&self, writer: &mut W) -> io::Result<()> { + match self { + Self::Automatic => BorshSerialize::serialize(&0u8, writer), + Self::LazyOnAccess => BorshSerialize::serialize(&1u8, writer), + Self::Coordinated { deadline } => { + BorshSerialize::serialize(&2u8, writer)?; + let dur = deadline.map(|d| (d.as_secs(), d.subsec_nanos())); + BorshSerialize::serialize(&dur, writer) + } + } + } + } + + impl BorshDeserialize for UpgradePolicy { + fn deserialize_reader(reader: &mut R) -> io::Result { + let tag = u8::deserialize_reader(reader)?; + match tag { + 0 => Ok(Self::Automatic), + 1 => Ok(Self::LazyOnAccess), + 2 => { + let dur: Option<(u64, u32)> = BorshDeserialize::deserialize_reader(reader)?; + Ok(Self::Coordinated { + deadline: dur + .map(|(s, n)| -> io::Result { + if n >= 1_000_000_000 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "nanoseconds field exceeds 999_999_999", + )); + } + Ok(Duration::new(s, n)) + }) + .transpose()?, + }) + } + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid UpgradePolicy tag", + )), + } + } + } +}; + +/// Distinguishes admin vs regular member within a context group. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshDeserialize, borsh::BorshSerialize) +)] +pub enum GroupMemberRole { + Admin, + Member, +} + +/// A serialized and encoded payload for inviting a user to join a Context Group. +/// +/// Internally Borsh-serialized for compact, deterministic representation and +/// then Base58-encoded for a human-readable string format. +/// Supports both targeted invitations (specific invitee) and open invitations (anyone can redeem). +#[derive(Clone, Serialize, Deserialize)] +#[serde(into = "String", try_from = "&str")] +pub struct GroupInvitationPayload(Vec); + +impl fmt::Debug for GroupInvitationPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut d = f.debug_struct("GroupInvitationPayload"); + _ = d.field("raw", &self.to_string()); + d.finish() + } +} + +impl fmt::Display for GroupInvitationPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(&bs58::encode(self.0.as_slice()).into_string()) + } +} + +impl FromStr for GroupInvitationPayload { + type Err = io::Error; + + fn from_str(s: &str) -> Result { + bs58::decode(s) + .into_vec() + .map(Self) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } +} + +impl From for String { + fn from(payload: GroupInvitationPayload) -> Self { + bs58::encode(payload.0.as_slice()).into_string() + } +} + +impl TryFrom<&str> for GroupInvitationPayload { + type Error = io::Error; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +#[cfg(feature = "borsh")] +const _: () = { + use borsh::{BorshDeserialize, BorshSerialize}; + + use crate::identity::PublicKey; + + #[derive(BorshSerialize, BorshDeserialize)] + struct GroupInvitationInner { + group_id: [u8; DIGEST_SIZE], + inviter_identity: [u8; DIGEST_SIZE], + invitee_identity: Option<[u8; DIGEST_SIZE]>, + expiration: Option, + protocol: String, + network_id: String, + contract_id: String, + inviter_signature: String, + secret_salt: [u8; 32], + expiration_block_height: u64, + } + + impl GroupInvitationPayload { + /// Creates a new, serialized group invitation payload. + /// + /// # Arguments + /// * `group_id` - The 32-byte group identifier. + /// * `inviter_identity` - The public key of the admin who created the invitation. + /// * `invitee_identity` - Optional specific invitee. `None` means open invitation. + /// * `expiration` - Optional unix timestamp after which the invitation is invalid. + /// * `protocol` - Protocol name (e.g., "near"). + /// * `network_id` - Network identifier (e.g., "testnet"). + /// * `contract_id` - Contract account ID on the external network. + /// * `inviter_signature` - Hex-encoded admin signature over the invitation. + /// * `secret_salt` - Random salt for MEV protection. + /// * `expiration_block_height` - Block-height expiration for the contract. + #[allow(clippy::too_many_arguments)] + pub fn new( + group_id: [u8; DIGEST_SIZE], + inviter_identity: PublicKey, + invitee_identity: Option, + expiration: Option, + protocol: &str, + network_id: &str, + contract_id: &str, + inviter_signature: String, + secret_salt: [u8; 32], + expiration_block_height: u64, + ) -> io::Result { + let payload = GroupInvitationInner { + group_id, + inviter_identity: *inviter_identity, + invitee_identity: invitee_identity.map(|pk| *pk), + expiration, + protocol: protocol.to_owned(), + network_id: network_id.to_owned(), + contract_id: contract_id.to_owned(), + inviter_signature, + secret_salt, + expiration_block_height, + }; + + borsh::to_vec(&payload).map(Self) + } + + /// Deserializes the payload and extracts its constituent parts. + /// + /// # Returns + /// A tuple of `(group_id_bytes, inviter_identity, invitee_identity, expiration, + /// protocol, network_id, contract_id, inviter_signature, secret_salt, + /// expiration_block_height)`. + #[allow(clippy::type_complexity)] + pub fn parts( + &self, + ) -> io::Result<( + [u8; DIGEST_SIZE], + PublicKey, + Option, + Option, + String, + String, + String, + String, + [u8; 32], + u64, + )> { + let payload: GroupInvitationInner = borsh::from_slice(&self.0)?; + + Ok(( + payload.group_id, + payload.inviter_identity.into(), + payload.invitee_identity.map(Into::into), + payload.expiration, + payload.protocol, + payload.network_id, + payload.contract_id, + payload.inviter_signature, + payload.secret_salt, + payload.expiration_block_height, + )) + } + } +}; + #[cfg(test)] mod tests { use super::*; @@ -395,4 +620,109 @@ mod tests { Err(InvalidContextId(HashError::DecodeError(_))) )); } + + #[test] + fn test_group_invitation_payload_roundtrip_targeted() { + let group_id = [3u8; DIGEST_SIZE]; + let inviter = PublicKey::from([4; DIGEST_SIZE]); + let invitee = PublicKey::from([5; DIGEST_SIZE]); + let salt = [9u8; 32]; + + let payload = GroupInvitationPayload::new( + group_id, + inviter, + Some(invitee), + Some(1_700_000_000), + "near", + "testnet", + "calimero.testnet", + "abcd1234".to_string(), + salt, + 999_999_999, + ) + .expect("Payload creation should succeed"); + + let encoded = payload.to_string(); + assert!(!encoded.is_empty()); + + let decoded = + GroupInvitationPayload::from_str(&encoded).expect("Payload decoding should succeed"); + + let ( + g, + inv, + invitee_out, + exp, + protocol, + network_id, + contract_id, + sig, + decoded_salt, + exp_bh, + ) = decoded.parts().expect("Parts extraction should succeed"); + assert_eq!(g, group_id); + assert_eq!(inv, inviter); + assert_eq!(invitee_out, Some(invitee)); + assert_eq!(exp, Some(1_700_000_000)); + assert_eq!(protocol, "near"); + assert_eq!(network_id, "testnet"); + assert_eq!(contract_id, "calimero.testnet"); + assert_eq!(sig, "abcd1234"); + assert_eq!(decoded_salt, salt); + assert_eq!(exp_bh, 999_999_999); + } + + #[test] + fn test_group_invitation_payload_roundtrip_open() { + let group_id = [6u8; DIGEST_SIZE]; + let inviter = PublicKey::from([7; DIGEST_SIZE]); + let salt = [10u8; 32]; + + let payload = GroupInvitationPayload::new( + group_id, + inviter, + None, + None, + "near", + "testnet", + "c.near", + "sig_hex".to_string(), + salt, + 1_000_000_000, + ) + .expect("Payload creation should succeed"); + + let encoded = payload.to_string(); + let decoded = + GroupInvitationPayload::from_str(&encoded).expect("Payload decoding should succeed"); + + let ( + g, + inv, + invitee_out, + exp, + protocol, + network_id, + contract_id, + sig, + decoded_salt, + exp_bh, + ) = decoded.parts().expect("Parts extraction should succeed"); + assert_eq!(g, group_id); + assert_eq!(inv, inviter); + assert_eq!(invitee_out, None); + assert_eq!(exp, None); + assert_eq!(protocol, "near"); + assert_eq!(network_id, "testnet"); + assert_eq!(contract_id, "c.near"); + assert_eq!(sig, "sig_hex"); + assert_eq!(decoded_salt, salt); + assert_eq!(exp_bh, 1_000_000_000); + } + + #[test] + fn test_group_invitation_payload_invalid_base58() { + let result = GroupInvitationPayload::from_str("This is not valid Base58!"); + assert!(result.is_err()); + } } diff --git a/crates/server/primitives/src/admin.rs b/crates/server/primitives/src/admin.rs index 8dd4e4fda6..8531966c6f 100644 --- a/crates/server/primitives/src/admin.rs +++ b/crates/server/primitives/src/admin.rs @@ -2,12 +2,15 @@ use std::collections::BTreeMap; use calimero_context_config::repr::Repr; use calimero_context_config::types::{ - BlockHeight, Capability, ContextIdentity, ContextStorageEntry, SignedOpenInvitation, + BlockHeight, Capability, ContextIdentity, ContextStorageEntry, SignedGroupOpenInvitation, + SignedOpenInvitation, }; use calimero_context_config::{Proposal, ProposalWithApprovals}; use calimero_primitives::alias::Alias; use calimero_primitives::application::{Application, ApplicationId}; -use calimero_primitives::context::{Context, ContextId, ContextInvitationPayload}; +use calimero_primitives::context::{ + Context, ContextId, ContextInvitationPayload, GroupMemberRole, UpgradePolicy, +}; use calimero_primitives::hash::Hash; use calimero_primitives::identity::{ClientKey, ContextUser, PublicKey, WalletType}; use camino::Utf8PathBuf; @@ -221,6 +224,10 @@ pub struct CreateContextRequest { pub application_id: ApplicationId, pub context_seed: Option, pub initialization_params: Vec, + pub group_id: Option, + pub identity_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, } impl CreateContextRequest { @@ -229,12 +236,17 @@ impl CreateContextRequest { application_id: ApplicationId, context_seed: Option, initialization_params: Vec, + group_id: Option, + identity_secret: Option, ) -> Self { Self { protocol, application_id, context_seed, initialization_params, + group_id, + identity_secret, + alias: None, } } } @@ -263,6 +275,14 @@ impl CreateContextResponse { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteContextApiRequest { + /// Identity of the caller. Required when deleting a group-attached context; + /// the caller must be a group admin. + pub requester: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeletedContextResponseData { @@ -1781,3 +1801,701 @@ impl Validate for JwtRefreshRequest { errors } } + +// -------------------------------------------- Group API -------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupApiRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_key: Option, + pub application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +impl Validate for CreateGroupApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if let Some(ref app_key) = self.app_key { + if app_key.is_empty() { + errors.push(ValidationError::EmptyField { field: "app_key" }); + } + } + errors + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupApiResponse { + pub data: CreateGroupApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupApiResponseData { + pub group_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteGroupApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for DeleteGroupApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteGroupApiResponse { + pub data: DeleteGroupApiResponseData, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteGroupApiResponseData { + pub is_deleted: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupInfoApiResponse { + pub data: GroupInfoApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupInfoApiResponseData { + pub group_id: String, + pub app_key: String, + pub target_application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub member_count: u64, + pub context_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_upgrade: Option, + pub default_capabilities: u32, + pub default_visibility: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddGroupMembersApiRequest { + pub members: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for AddGroupMembersApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.members.is_empty() { + errors.push(ValidationError::EmptyField { field: "members" }); + } + errors + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupMemberApiInput { + pub identity: PublicKey, + pub role: GroupMemberRole, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveGroupMembersApiRequest { + pub members: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for RemoveGroupMembersApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.members.is_empty() { + errors.push(ValidationError::EmptyField { field: "members" }); + } + errors + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListGroupMembersApiResponse { + pub data: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupMemberApiEntry { + pub identity: PublicKey, + pub role: GroupMemberRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ListGroupMembersQuery { + pub offset: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupContextEntryResponse { + pub context_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListGroupContextsApiResponse { + pub data: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ListGroupContextsQuery { + pub offset: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpgradeGroupApiRequest { + pub target_application_id: ApplicationId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrate_method: Option, +} + +impl Validate for UpgradeGroupApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if let Some(ref method) = self.migrate_method { + if let Some(e) = + validate_string_length(method, "migrate_method", MAX_METHOD_NAME_LENGTH) + { + errors.push(e); + } + if method.is_empty() { + errors.push(ValidationError::EmptyField { + field: "migrate_method", + }); + } + } + errors + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpgradeGroupApiResponse { + pub data: UpgradeGroupApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpgradeGroupApiResponseData { + pub group_id: String, + pub status: String, + pub total: Option, + pub completed: Option, + pub failed: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetGroupUpgradeStatusApiResponse { + pub data: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupUpgradeStatusApiData { + pub from_version: String, + pub to_version: String, + pub initiated_at: u64, + pub initiated_by: PublicKey, + pub status: String, + pub total: Option, + pub completed: Option, + pub failed: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetryGroupUpgradeApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for RetryGroupUpgradeApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupInvitationApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, + /// On-chain block height after which the invitation commitment expires. + /// Defaults to 999_999_999 when not provided (backward-compatible). + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration_block_height: Option, +} + +impl Validate for CreateGroupInvitationApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupInvitationApiResponse { + pub data: CreateGroupInvitationApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateGroupInvitationApiResponseData { + pub invitation: SignedGroupOpenInvitation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_alias: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupApiRequest { + pub invitation: SignedGroupOpenInvitation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_alias: Option, +} + +impl Validate for JoinGroupApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupApiResponse { + pub data: JoinGroupApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupApiResponseData { + pub group_id: String, + pub member_identity: PublicKey, +} + +// ---- List All Groups ---- + +#[derive(Clone, Debug, Deserialize)] +pub struct ListAllGroupsQuery { + pub offset: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListAllGroupsApiResponse { + pub data: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupSummaryApiData { + pub group_id: String, + pub app_key: String, + pub target_application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub created_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +// ---- Update Group Settings ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGroupSettingsApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, + pub upgrade_policy: UpgradePolicy, +} + +impl Validate for UpdateGroupSettingsApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +// ---- Update Group Settings ---- + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct UpdateGroupSettingsApiResponse; + +// ---- Update Member Role ---- + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct UpdateMemberRoleApiResponse; + +// ---- Add Group Members (empty response) ---- + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct AddGroupMembersApiResponse; + +// ---- Remove Group Members (empty response) ---- + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct RemoveGroupMembersApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemberRoleApiRequest { + pub role: GroupMemberRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for UpdateMemberRoleApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +// ---- Detach Context From Group ---- + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct DetachContextFromGroupApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DetachContextFromGroupApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for DetachContextFromGroupApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +// ---- Register Group Signing Key ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterGroupSigningKeyApiRequest { + pub signing_key: String, +} + +impl Validate for RegisterGroupSigningKeyApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.signing_key.is_empty() { + errors.push(ValidationError::EmptyField { + field: "signing_key", + }); + } + errors + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterGroupSigningKeyApiResponse { + pub data: RegisterGroupSigningKeyApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterGroupSigningKeyApiResponseData { + pub public_key: PublicKey, +} + +// ---- Sync Group ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncGroupApiRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub network_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_id: Option, +} + +impl Validate for SyncGroupApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncGroupApiResponse { + pub data: SyncGroupApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncGroupApiResponseData { + pub group_id: String, + pub app_key: String, + pub target_application_id: ApplicationId, + pub member_count: u64, + pub context_count: u64, +} + +// ---- Join Group Context ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupContextApiRequest { + pub context_id: ContextId, +} + +impl Validate for JoinGroupContextApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupContextApiResponse { + pub data: JoinGroupContextApiResponseData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinGroupContextApiResponseData { + pub context_id: ContextId, + pub member_public_key: PublicKey, +} + +// ---- Get Context Group ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetContextGroupApiResponse { + pub data: Option, +} + +// ---- Group Permissions API ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetMemberCapabilitiesApiRequest { + pub capabilities: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetMemberCapabilitiesApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetMemberCapabilitiesApiResponse; + +// ---- Set Member Alias ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetMemberAliasApiRequest { + pub alias: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetMemberAliasApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.alias.is_empty() { + errors.push(ValidationError::EmptyField { field: "alias" }); + } + if let Some(e) = validate_string_length(&self.alias, "alias", 64) { + errors.push(e); + } + errors + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetMemberAliasApiResponse; + +// ---- Set Group Alias ---- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetGroupAliasApiRequest { + pub alias: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetGroupAliasApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.alias.is_empty() { + errors.push(ValidationError::EmptyField { field: "alias" }); + } + if let Some(e) = validate_string_length(&self.alias, "alias", 64) { + errors.push(e); + } + errors + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetGroupAliasApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetMemberCapabilitiesApiResponse { + pub data: GetMemberCapabilitiesApiData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetMemberCapabilitiesApiData { + pub capabilities: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetContextVisibilityApiRequest { + pub mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetContextVisibilityApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.mode != "open" && self.mode != "restricted" { + errors.push(ValidationError::InvalidFormat { + field: "mode", + reason: "must be 'open' or 'restricted'".into(), + }); + } + errors + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetContextVisibilityApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetContextVisibilityApiResponse { + pub data: GetContextVisibilityApiData, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetContextVisibilityApiData { + pub mode: String, + pub creator: PublicKey, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManageContextAllowlistApiRequest { + #[serde(default)] + pub add: Vec, + #[serde(default)] + pub remove: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for ManageContextAllowlistApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.add.is_empty() && self.remove.is_empty() { + errors.push(ValidationError::EmptyField { + field: "add/remove", + }); + } + errors + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct ManageContextAllowlistApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetContextAllowlistApiResponse { + pub data: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultCapabilitiesApiRequest { + pub default_capabilities: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetDefaultCapabilitiesApiRequest { + fn validate(&self) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetDefaultCapabilitiesApiResponse; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultVisibilityApiRequest { + pub default_visibility: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requester: Option, +} + +impl Validate for SetDefaultVisibilityApiRequest { + fn validate(&self) -> Vec { + let mut errors = Vec::new(); + if self.default_visibility != "open" && self.default_visibility != "restricted" { + errors.push(ValidationError::InvalidFormat { + field: "default_visibility", + reason: "must be 'open' or 'restricted'".into(), + }); + } + errors + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct SetDefaultVisibilityApiResponse; diff --git a/crates/server/src/admin/handlers.rs b/crates/server/src/admin/handlers.rs index 11ad2be9cb..188a735c65 100644 --- a/crates/server/src/admin/handlers.rs +++ b/crates/server/src/admin/handlers.rs @@ -3,6 +3,7 @@ pub mod applications; pub mod blob; pub mod context; pub mod get_latest_version; +pub mod groups; pub mod identity; pub mod list_packages; pub mod list_versions; diff --git a/crates/server/src/admin/handlers/context.rs b/crates/server/src/admin/handlers/context.rs index 3329ae6854..d6028d286e 100644 --- a/crates/server/src/admin/handlers/context.rs +++ b/crates/server/src/admin/handlers/context.rs @@ -1,6 +1,7 @@ pub mod create_context; pub mod delete_context; pub mod get_context; +pub mod get_context_group; pub mod get_context_identities; pub mod get_context_ids; pub mod get_context_storage; diff --git a/crates/server/src/admin/handlers/context/create_context.rs b/crates/server/src/admin/handlers/context/create_context.rs index 2be43c0204..b0da638246 100644 --- a/crates/server/src/admin/handlers/context/create_context.rs +++ b/crates/server/src/admin/handlers/context/create_context.rs @@ -1,14 +1,17 @@ use std::sync::Arc; +use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Extension; +use calimero_context_config::types::ContextGroupId; +use calimero_primitives::identity::PrivateKey; use calimero_server_primitives::admin::{ CreateContextRequest, CreateContextResponse, CreateContextResponseData, }; use tracing::{error, info}; use crate::admin::handlers::validation::ValidatedJson; -use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::admin::service::{parse_api_error, ApiError, ApiResponse}; use crate::AdminState; pub async fn handler( @@ -17,14 +20,31 @@ pub async fn handler( ) -> impl IntoResponse { info!(application_id=%req.application_id, "Creating context"); + let group_id = match req.group_id.as_deref().map(parse_group_id).transpose() { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let identity_secret = match req + .identity_secret + .as_deref() + .map(decode_identity_secret) + .transpose() + { + Ok(key) => key, + Err(err) => return err.into_response(), + }; + let result = state .ctx_client .create_context( req.protocol, &req.application_id, - None, + identity_secret, req.initialization_params, req.context_seed.map(Into::into), + group_id, + req.alias, ) .await .map_err(parse_api_error); @@ -48,3 +68,27 @@ pub async fn handler( } } } + +fn parse_group_id(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid group_id: expected hex-encoded 32 bytes".into(), + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid group_id: must be exactly 32 bytes".into(), + })?; + Ok(ContextGroupId::from(arr)) +} + +fn decode_identity_secret(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid identity_secret: expected hex-encoded 32 bytes".into(), + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid identity_secret: must be exactly 32 bytes".into(), + })?; + Ok(PrivateKey::from(arr)) +} diff --git a/crates/server/src/admin/handlers/context/delete_context.rs b/crates/server/src/admin/handlers/context/delete_context.rs index a3ba29d930..88655926f0 100644 --- a/crates/server/src/admin/handlers/context/delete_context.rs +++ b/crates/server/src/admin/handlers/context/delete_context.rs @@ -3,20 +3,25 @@ use std::sync::Arc; use axum::extract::Path; use axum::response::IntoResponse; -use axum::Extension; +use axum::{Extension, Json}; use calimero_primitives::context::ContextId; -use calimero_server_primitives::admin::{DeleteContextResponse, DeletedContextResponseData}; +use calimero_server_primitives::admin::{ + DeleteContextApiRequest, DeleteContextResponse, DeletedContextResponseData, +}; use reqwest::StatusCode; use tower_sessions::Session; use tracing::{error, info}; use crate::admin::service::{parse_api_error, ApiError, ApiResponse}; +use crate::auth::AuthenticatedKey; use crate::AdminState; pub async fn handler( Path(context_id): Path, _session: Session, Extension(state): Extension>, + auth_key: Option>, + body: Option>, ) -> impl IntoResponse { let context_id_result = match ContextId::from_str(&context_id) { Ok(id) => id, @@ -30,12 +35,18 @@ pub async fn handler( } }; + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key + .map(|Extension(k)| k.0) + .or_else(|| body.and_then(|Json(req)| req.requester)); + info!(context_id=%context_id_result, "Deleting context"); // todo! experiment with Interior: WriteLayer let result = state .ctx_client - .delete_context(&context_id_result) + .delete_context(&context_id_result, requester) .await .map_err(parse_api_error); diff --git a/crates/server/src/admin/handlers/context/get_context_group.rs b/crates/server/src/admin/handlers/context/get_context_group.rs new file mode 100644 index 0000000000..b8b0483b8d --- /dev/null +++ b/crates/server/src/admin/handlers/context/get_context_group.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetGroupForContextRequest; +use calimero_primitives::context::ContextId; +use calimero_server_primitives::admin::GetContextGroupApiResponse; +use tracing::{error, info}; + +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(context_id): Path, + Extension(state): Extension>, +) -> impl IntoResponse { + info!(%context_id, "Getting group for context"); + + let result = state + .ctx_client + .get_group_for_context(GetGroupForContextRequest { context_id }) + .await + .map_err(parse_api_error); + + match result { + Ok(group_id) => { + info!(%context_id, "Context group retrieved successfully"); + ApiResponse { + payload: GetContextGroupApiResponse { + data: group_id.map(|g| hex::encode(g.to_bytes())), + }, + } + .into_response() + } + Err(err) => { + error!(%context_id, error=?err, "Failed to get group for context"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups.rs b/crates/server/src/admin/handlers/groups.rs new file mode 100644 index 0000000000..6e6455a81d --- /dev/null +++ b/crates/server/src/admin/handlers/groups.rs @@ -0,0 +1,152 @@ +pub mod add_group_members; +pub mod create_group; +pub mod create_group_invitation; +pub mod delete_group; +pub mod detach_context_from_group; +pub mod get_context_allowlist; +pub mod get_context_visibility; +pub mod get_group_info; +pub mod get_group_upgrade_status; +pub mod get_member_capabilities; +pub mod join_group; +pub mod join_group_context; +pub mod list_all_groups; +pub mod list_group_contexts; +pub mod list_group_members; +pub mod manage_context_allowlist; +pub mod register_signing_key; +pub mod remove_group_members; +pub mod retry_group_upgrade; +pub mod set_context_visibility; +pub mod set_default_capabilities; +pub mod set_default_visibility; +pub mod set_group_alias; +pub mod set_member_alias; +pub mod set_member_capabilities; +pub mod sync_group; +pub mod update_group_settings; +pub mod update_member_role; +pub mod upgrade_group; + +use calimero_context_config::types::ContextGroupId; +use calimero_context_primitives::group::{GroupUpgradeInfo, GroupUpgradeStatus}; +use calimero_primitives::context::ContextId; +use calimero_primitives::identity::PublicKey; +use calimero_server_primitives::admin::GroupUpgradeStatusApiData; +use reqwest::StatusCode; + +use crate::admin::service::ApiError; + +fn upgrade_info_to_api_data(info: &GroupUpgradeInfo) -> GroupUpgradeStatusApiData { + let (status, total, completed, failed, completed_at) = match &info.status { + GroupUpgradeStatus::InProgress { + total, + completed, + failed, + } => ( + "in_progress", + Some(*total), + Some(*completed), + Some(*failed), + None, + ), + GroupUpgradeStatus::Completed { completed_at } => { + ("completed", None, None, None, *completed_at) + } + }; + + GroupUpgradeStatusApiData { + from_version: info.from_version.clone(), + to_version: info.to_version.clone(), + initiated_at: info.initiated_at, + initiated_by: info.initiated_by, + status: status.to_owned(), + total, + completed, + failed, + completed_at, + } +} + +fn parse_group_id(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid group id format: expected hex-encoded 32 bytes".into(), + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid group id: must be exactly 32 bytes".into(), + })?; + Ok(ContextGroupId::from(arr)) +} + +fn parse_context_id(s: &str) -> Result { + if let Ok(context_id) = s.parse::() { + return Ok(context_id); + } + + let bytes = hex::decode(s).map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid context id format: expected base58 or hex-encoded 32 bytes".into(), + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid context id: must be exactly 32 bytes".into(), + })?; + Ok(ContextId::from(arr)) +} + +fn parse_identity(s: &str) -> Result { + if let Ok(identity) = s.parse::() { + return Ok(identity); + } + + let bytes = hex::decode(s).map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid identity format: expected public key or hex-encoded 32 bytes".into(), + })?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid identity: must be exactly 32 bytes".into(), + })?; + Ok(PublicKey::from(arr)) +} + +#[cfg(test)] +mod tests { + use calimero_primitives::identity::PublicKey; + + use super::{parse_context_id, parse_identity}; + + #[test] + fn parse_context_id_accepts_base58_context_ids() { + let context_id = parse_context_id("11111111111111111111111111111111"); + + assert!(context_id.is_ok()); + } + + #[test] + fn parse_context_id_keeps_accepting_hex_context_ids() { + let context_id = + parse_context_id("0000000000000000000000000000000000000000000000000000000000000000"); + + assert!(context_id.is_ok()); + } + + #[test] + fn parse_identity_accepts_public_key_strings() { + let identity = PublicKey::from([0; 32]).to_string(); + + let parsed_identity = parse_identity(&identity); + + assert!(parsed_identity.is_ok()); + } + + #[test] + fn parse_identity_keeps_accepting_hex_identities() { + let identity = + parse_identity("0000000000000000000000000000000000000000000000000000000000000000"); + + assert!(identity.is_ok()); + } +} diff --git a/crates/server/src/admin/handlers/groups/add_group_members.rs b/crates/server/src/admin/handlers/groups/add_group_members.rs new file mode 100644 index 0000000000..f5dc53047a --- /dev/null +++ b/crates/server/src/admin/handlers/groups/add_group_members.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::AddGroupMembersRequest; +use calimero_server_primitives::admin::AddGroupMembersApiRequest; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::auth::AuthenticatedKey; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + auth_key: Option>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, count=%req.members.len(), "Adding group members"); + + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key.map(|Extension(k)| k.0).or(req.requester); + + let members = req + .members + .into_iter() + .map(|m| (m.identity, m.role)) + .collect(); + + let result = state + .ctx_client + .add_group_members(AddGroupMembersRequest { + group_id, + members, + requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Group members added successfully"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to add group members"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/create_group.rs b/crates/server/src/admin/handlers/groups/create_group.rs new file mode 100644 index 0000000000..775e36e719 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/create_group.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_config::types::AppKey; +use calimero_context_primitives::group::CreateGroupRequest; +use calimero_server_primitives::admin::{ + CreateGroupApiRequest, CreateGroupApiResponse, CreateGroupApiResponseData, +}; +use reqwest::StatusCode; +use tracing::{error, info}; + +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiError, ApiResponse}; +use crate::AdminState; + +use super::parse_group_id; + +pub async fn handler( + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let app_key = match &req.app_key { + Some(hex_str) => { + let bytes: [u8; 32] = match hex::decode(hex_str) + .map_err(|_| ()) + .and_then(|v| v.try_into().map_err(|_| ())) + { + Ok(b) => b, + Err(()) => { + return ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid app_key: expected hex-encoded 32 bytes".into(), + } + .into_response(); + } + }; + Some(AppKey::from(bytes)) + } + None => None, + }; + + let group_id = match req.group_id.as_deref().map(parse_group_id) { + Some(Ok(id)) => Some(id), + Some(Err(err)) => return err.into_response(), + None => None, + }; + + info!(application_id=%req.application_id, "Creating group"); + + let result = state + .ctx_client + .create_group(CreateGroupRequest { + group_id, + app_key, + application_id: req.application_id, + upgrade_policy: req.upgrade_policy, + alias: req.alias, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(response) => { + let group_id_hex = hex::encode(response.group_id.to_bytes()); + info!(group_id=%group_id_hex, "Group created successfully"); + ApiResponse { + payload: CreateGroupApiResponse { + data: CreateGroupApiResponseData { + group_id: group_id_hex, + }, + }, + } + .into_response() + } + Err(err) => { + error!(error=?err, "Failed to create group"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/create_group_invitation.rs b/crates/server/src/admin/handlers/groups/create_group_invitation.rs new file mode 100644 index 0000000000..99a605e0a5 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/create_group_invitation.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::CreateGroupInvitationRequest; +use calimero_server_primitives::admin::{ + CreateGroupInvitationApiRequest, CreateGroupInvitationApiResponse, + CreateGroupInvitationApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::auth::AuthenticatedKey; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + auth_key: Option>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Creating group invitation"); + + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key.map(|Extension(k)| k.0).or(req.requester); + + let result = state + .ctx_client + .create_group_invitation(CreateGroupInvitationRequest { + group_id, + requester, + expiration_block_height: req.expiration_block_height, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + info!(group_id=%group_id_str, "Group invitation created"); + ApiResponse { + payload: CreateGroupInvitationApiResponse { + data: CreateGroupInvitationApiResponseData { + invitation: resp.invitation, + group_alias: resp.group_alias, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to create group invitation"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/delete_group.rs b/crates/server/src/admin/handlers/groups/delete_group.rs new file mode 100644 index 0000000000..a78fc581ed --- /dev/null +++ b/crates/server/src/admin/handlers/groups/delete_group.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::DeleteGroupRequest; +use calimero_server_primitives::admin::{ + DeleteGroupApiRequest, DeleteGroupApiResponse, DeleteGroupApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::auth::AuthenticatedKey; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + auth_key: Option>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Deleting group"); + + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key.map(|Extension(k)| k.0).or(req.requester); + + let result = state + .ctx_client + .delete_group(DeleteGroupRequest { + group_id, + requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(response) => { + info!(group_id=%group_id_str, deleted=%response.deleted, "Group deletion completed"); + ApiResponse { + payload: DeleteGroupApiResponse { + data: DeleteGroupApiResponseData { + is_deleted: response.deleted, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to delete group"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/detach_context_from_group.rs b/crates/server/src/admin/handlers/groups/detach_context_from_group.rs new file mode 100644 index 0000000000..ae0b2d6081 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/detach_context_from_group.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::DetachContextFromGroupRequest; +use calimero_server_primitives::admin::DetachContextFromGroupApiRequest; +use reqwest::StatusCode; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiError, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, context_id_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let context_id = match context_id_str.parse() { + Ok(id) => id, + Err(_) => { + return ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid context_id format".into(), + } + .into_response() + } + }; + + info!(group_id=%group_id_str, context_id=%context_id_str, "Detaching context from group"); + + let result = state + .ctx_client + .detach_context_from_group(DetachContextFromGroupRequest { + group_id, + context_id, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, context_id=%context_id_str, "Context detached from group successfully"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, context_id=%context_id_str, error=?err, "Failed to detach context from group"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/get_context_allowlist.rs b/crates/server/src/admin/handlers/groups/get_context_allowlist.rs new file mode 100644 index 0000000000..3af9773b2d --- /dev/null +++ b/crates/server/src/admin/handlers/groups/get_context_allowlist.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetContextAllowlistRequest; +use calimero_server_primitives::admin::GetContextAllowlistApiResponse; +use tracing::{error, info}; + +use super::{parse_context_id, parse_group_id}; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, context_id_str)): Path<(String, String)>, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let context_id = match parse_context_id(&context_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, context_id=%context_id_str, "Getting context allowlist"); + + let result = state + .ctx_client + .get_context_allowlist(GetContextAllowlistRequest { + group_id, + context_id, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(members) => { + info!(group_id=%group_id_str, context_id=%context_id_str, count=members.len(), "Got context allowlist"); + ApiResponse { + payload: GetContextAllowlistApiResponse { data: members }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, context_id=%context_id_str, error=?err, "Failed to get context allowlist"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/get_context_visibility.rs b/crates/server/src/admin/handlers/groups/get_context_visibility.rs new file mode 100644 index 0000000000..0957078a48 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/get_context_visibility.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetContextVisibilityRequest; +use calimero_server_primitives::admin::{ + GetContextVisibilityApiData, GetContextVisibilityApiResponse, +}; +use tracing::{error, info}; + +use super::{parse_context_id, parse_group_id}; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, context_id_str)): Path<(String, String)>, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let context_id = match parse_context_id(&context_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, context_id=%context_id_str, "Getting context visibility"); + + let result = state + .ctx_client + .get_context_visibility(GetContextVisibilityRequest { + group_id, + context_id, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(response) => { + let mode_str = match response.mode { + calimero_context_config::VisibilityMode::Open => "open", + calimero_context_config::VisibilityMode::Restricted => "restricted", + }; + + info!(group_id=%group_id_str, context_id=%context_id_str, "Got context visibility"); + ApiResponse { + payload: GetContextVisibilityApiResponse { + data: GetContextVisibilityApiData { + mode: mode_str.to_owned(), + creator: response.creator, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, context_id=%context_id_str, error=?err, "Failed to get context visibility"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/get_group_info.rs b/crates/server/src/admin/handlers/groups/get_group_info.rs new file mode 100644 index 0000000000..87387430a8 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/get_group_info.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetGroupInfoRequest; +use calimero_server_primitives::admin::{GroupInfoApiResponse, GroupInfoApiResponseData}; +use tracing::{error, info}; + +use super::{parse_group_id, upgrade_info_to_api_data}; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Getting group info"); + + let result = state + .ctx_client + .get_group_info(GetGroupInfoRequest { group_id }) + .await + .map_err(parse_api_error); + + match result { + Ok(info) => { + info!(group_id=%group_id_str, "Group info retrieved successfully"); + let active_upgrade = info.active_upgrade.as_ref().map(upgrade_info_to_api_data); + + ApiResponse { + payload: GroupInfoApiResponse { + data: GroupInfoApiResponseData { + group_id: hex::encode(info.group_id.to_bytes()), + app_key: hex::encode(info.app_key.to_bytes()), + target_application_id: info.target_application_id, + upgrade_policy: info.upgrade_policy, + member_count: info.member_count, + context_count: info.context_count, + active_upgrade, + default_capabilities: info.default_capabilities, + default_visibility: info.default_visibility, + alias: info.alias, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to get group info"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/get_group_upgrade_status.rs b/crates/server/src/admin/handlers/groups/get_group_upgrade_status.rs new file mode 100644 index 0000000000..92cd7f316d --- /dev/null +++ b/crates/server/src/admin/handlers/groups/get_group_upgrade_status.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetGroupUpgradeStatusRequest; +use calimero_server_primitives::admin::GetGroupUpgradeStatusApiResponse; +use tracing::{error, info}; + +use super::{parse_group_id, upgrade_info_to_api_data}; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Getting group upgrade status"); + + let result = state + .ctx_client + .get_group_upgrade_status(GetGroupUpgradeStatusRequest { group_id }) + .await + .map_err(parse_api_error); + + match result { + Ok(upgrade) => { + let data = upgrade.as_ref().map(upgrade_info_to_api_data); + + ApiResponse { + payload: GetGroupUpgradeStatusApiResponse { data }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to get upgrade status"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/get_member_capabilities.rs b/crates/server/src/admin/handlers/groups/get_member_capabilities.rs new file mode 100644 index 0000000000..c641246732 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/get_member_capabilities.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::GetMemberCapabilitiesRequest; +use calimero_server_primitives::admin::{ + GetMemberCapabilitiesApiData, GetMemberCapabilitiesApiResponse, +}; +use tracing::{error, info}; + +use super::{parse_group_id, parse_identity}; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, identity_str)): Path<(String, String)>, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let member = match parse_identity(&identity_str) { + Ok(pk) => pk, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, identity=%identity_str, "Getting member capabilities"); + + let result = state + .ctx_client + .get_member_capabilities(GetMemberCapabilitiesRequest { group_id, member }) + .await + .map_err(parse_api_error); + + match result { + Ok(response) => { + info!(group_id=%group_id_str, identity=%identity_str, "Got member capabilities"); + ApiResponse { + payload: GetMemberCapabilitiesApiResponse { + data: GetMemberCapabilitiesApiData { + capabilities: response.capabilities, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, identity=%identity_str, error=?err, "Failed to get member capabilities"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/join_group.rs b/crates/server/src/admin/handlers/groups/join_group.rs new file mode 100644 index 0000000000..8ba19a9aca --- /dev/null +++ b/crates/server/src/admin/handlers/groups/join_group.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::JoinGroupRequest; +use calimero_server_primitives::admin::{ + JoinGroupApiRequest, JoinGroupApiResponse, JoinGroupApiResponseData, +}; +use tracing::{error, info}; + +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + info!("Joining group via invitation"); + + let result = state + .ctx_client + .join_group(JoinGroupRequest { + invitation: req.invitation, + group_alias: req.group_alias, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + let group_id_hex = hex::encode(resp.group_id.to_bytes()); + info!(group_id=%group_id_hex, member=%resp.member_identity, "Joined group successfully"); + ApiResponse { + payload: JoinGroupApiResponse { + data: JoinGroupApiResponseData { + group_id: group_id_hex, + member_identity: resp.member_identity, + }, + }, + } + .into_response() + } + Err(err) => { + error!(error=?err, "Failed to join group"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/join_group_context.rs b/crates/server/src/admin/handlers/groups/join_group_context.rs new file mode 100644 index 0000000000..606b312651 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/join_group_context.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::JoinGroupContextRequest; +use calimero_server_primitives::admin::{ + JoinGroupContextApiRequest, JoinGroupContextApiResponse, JoinGroupContextApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, context_id=%req.context_id, "Joining context via group membership"); + + let result = state + .ctx_client + .join_group_context(JoinGroupContextRequest { + group_id, + context_id: req.context_id, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + info!( + group_id=%group_id_str, + context_id=%resp.context_id, + member=%resp.member_public_key, + "Successfully joined context via group" + ); + ApiResponse { + payload: JoinGroupContextApiResponse { + data: JoinGroupContextApiResponseData { + context_id: resp.context_id, + member_public_key: resp.member_public_key, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to join context via group"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/list_all_groups.rs b/crates/server/src/admin/handlers/groups/list_all_groups.rs new file mode 100644 index 0000000000..53b8330719 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/list_all_groups.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use axum::extract::Query; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::ListAllGroupsRequest; +use calimero_server_primitives::admin::{ + GroupSummaryApiData, ListAllGroupsApiResponse, ListAllGroupsQuery, +}; +use tracing::{error, info}; + +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Query(query): Query, + Extension(state): Extension>, +) -> impl IntoResponse { + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(100); + + info!(%offset, %limit, "Listing all groups"); + + let result = state + .ctx_client + .list_all_groups(ListAllGroupsRequest { offset, limit }) + .await + .map_err(parse_api_error); + + match result { + Ok(groups) => { + info!(count=%groups.len(), "Groups retrieved successfully"); + let data = groups + .into_iter() + .map(|g| GroupSummaryApiData { + group_id: hex::encode(g.group_id.to_bytes()), + app_key: hex::encode(g.app_key.to_bytes()), + target_application_id: g.target_application_id, + upgrade_policy: g.upgrade_policy, + created_at: g.created_at, + alias: g.alias, + }) + .collect(); + ApiResponse { + payload: ListAllGroupsApiResponse { data }, + } + .into_response() + } + Err(err) => { + error!(error=?err, "Failed to list groups"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/list_group_contexts.rs b/crates/server/src/admin/handlers/groups/list_group_contexts.rs new file mode 100644 index 0000000000..2fc517c681 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/list_group_contexts.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::ListGroupContextsRequest; +use calimero_server_primitives::admin::{ + GroupContextEntryResponse, ListGroupContextsApiResponse, ListGroupContextsQuery, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Query(query): Query, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(100); + + info!(group_id=%group_id_str, %offset, %limit, "Listing group contexts"); + + let result = state + .ctx_client + .list_group_contexts(ListGroupContextsRequest { + group_id, + offset, + limit, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(entries) => { + info!(group_id=%group_id_str, count=%entries.len(), "Group contexts retrieved successfully"); + let data = entries + .into_iter() + .map(|e| GroupContextEntryResponse { + context_id: hex::encode(*e.context_id), + alias: e.alias, + }) + .collect(); + ApiResponse { + payload: ListGroupContextsApiResponse { data }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to list group contexts"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/list_group_members.rs b/crates/server/src/admin/handlers/groups/list_group_members.rs new file mode 100644 index 0000000000..4f048ca48b --- /dev/null +++ b/crates/server/src/admin/handlers/groups/list_group_members.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::ListGroupMembersRequest; +use calimero_server_primitives::admin::{ + GroupMemberApiEntry, ListGroupMembersApiResponse, ListGroupMembersQuery, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Query(query): Query, + Extension(state): Extension>, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(100); + + info!(group_id=%group_id_str, %offset, %limit, "Listing group members"); + + let result = state + .ctx_client + .list_group_members(ListGroupMembersRequest { + group_id, + offset, + limit, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(members) => { + info!(group_id=%group_id_str, count=%members.len(), "Group members retrieved successfully"); + let entries = members + .into_iter() + .map(|m| GroupMemberApiEntry { + identity: m.identity, + role: m.role, + alias: m.alias, + }) + .collect(); + ApiResponse { + payload: ListGroupMembersApiResponse { data: entries }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to list group members"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/manage_context_allowlist.rs b/crates/server/src/admin/handlers/groups/manage_context_allowlist.rs new file mode 100644 index 0000000000..fb4fef3e62 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/manage_context_allowlist.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::ManageContextAllowlistRequest; +use calimero_server_primitives::admin::ManageContextAllowlistApiRequest; +use tracing::{error, info}; + +use super::{parse_context_id, parse_group_id}; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, context_id_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let context_id = match parse_context_id(&context_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!( + group_id=%group_id_str, + context_id=%context_id_str, + add_count=req.add.len(), + remove_count=req.remove.len(), + "Managing context allowlist" + ); + + let result = state + .ctx_client + .manage_context_allowlist(ManageContextAllowlistRequest { + group_id, + context_id, + add: req.add, + remove: req.remove, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, context_id=%context_id_str, "Context allowlist updated"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, context_id=%context_id_str, error=?err, "Failed to manage context allowlist"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/register_signing_key.rs b/crates/server/src/admin/handlers/groups/register_signing_key.rs new file mode 100644 index 0000000000..878c5f0347 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/register_signing_key.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_primitives::identity::PrivateKey; +use calimero_server_primitives::admin::{ + RegisterGroupSigningKeyApiRequest, RegisterGroupSigningKeyApiResponse, + RegisterGroupSigningKeyApiResponseData, +}; +use calimero_store::key::{GroupMember, GroupSigningKey, GroupSigningKeyValue}; +use reqwest::StatusCode; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{ApiError, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let private_key_bytes: [u8; 32] = match hex::decode(&req.signing_key) + .map_err(|_| ()) + .and_then(|v| v.try_into().map_err(|_| ())) + { + Ok(bytes) => bytes, + Err(()) => { + return ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "Invalid signing_key: expected hex-encoded 32 bytes".into(), + } + .into_response(); + } + }; + + let private_key = PrivateKey::from(private_key_bytes); + let public_key = private_key.public_key(); + + // Verify the identity is a member of this group + let handle = state.store.handle(); + let member_key = GroupMember::new(group_id.to_bytes(), public_key); + match handle.has(&member_key) { + Ok(true) => {} + Ok(false) => { + return ApiError { + status_code: StatusCode::FORBIDDEN, + message: "Identity derived from signing key is not a member of this group".into(), + } + .into_response(); + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to check group membership"); + return ApiError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Failed to check group membership: {err}"), + } + .into_response(); + } + } + drop(handle); + + // Store the signing key + let mut handle = state.store.handle(); + let store_key = GroupSigningKey::new(group_id.to_bytes(), public_key); + if let Err(err) = handle.put( + &store_key, + &GroupSigningKeyValue { + private_key: private_key_bytes, + }, + ) { + error!(group_id=%group_id_str, error=?err, "Failed to store signing key"); + return ApiError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Failed to store signing key: {err}"), + } + .into_response(); + } + + info!(group_id=%group_id_str, %public_key, "Group signing key registered"); + + ApiResponse { + payload: RegisterGroupSigningKeyApiResponse { + data: RegisterGroupSigningKeyApiResponseData { public_key }, + }, + } + .into_response() +} diff --git a/crates/server/src/admin/handlers/groups/remove_group_members.rs b/crates/server/src/admin/handlers/groups/remove_group_members.rs new file mode 100644 index 0000000000..ed002815b6 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/remove_group_members.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::RemoveGroupMembersRequest; +use calimero_server_primitives::admin::RemoveGroupMembersApiRequest; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::auth::AuthenticatedKey; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + auth_key: Option>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, count=%req.members.len(), "Removing group members"); + + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key.map(|Extension(k)| k.0).or(req.requester); + + let result = state + .ctx_client + .remove_group_members(RemoveGroupMembersRequest { + group_id, + members: req.members, + requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Group members removed successfully"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to remove group members"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/retry_group_upgrade.rs b/crates/server/src/admin/handlers/groups/retry_group_upgrade.rs new file mode 100644 index 0000000000..10d5a2341a --- /dev/null +++ b/crates/server/src/admin/handlers/groups/retry_group_upgrade.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::RetryGroupUpgradeRequest; +use calimero_server_primitives::admin::{ + RetryGroupUpgradeApiRequest, UpgradeGroupApiResponse, UpgradeGroupApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use super::upgrade_group::format_status; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Retrying group upgrade"); + + let result = state + .ctx_client + .retry_group_upgrade(RetryGroupUpgradeRequest { + group_id, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + let (status_str, total, completed, failed) = format_status(&resp.status); + info!(group_id=%group_id_str, "Group upgrade retry initiated"); + ApiResponse { + payload: UpgradeGroupApiResponse { + data: UpgradeGroupApiResponseData { + group_id: hex::encode(resp.group_id.to_bytes()), + status: status_str, + total, + completed, + failed, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to retry group upgrade"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_context_visibility.rs b/crates/server/src/admin/handlers/groups/set_context_visibility.rs new file mode 100644 index 0000000000..e8d49c8c63 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_context_visibility.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_config::VisibilityMode; +use calimero_context_primitives::group::SetContextVisibilityRequest; +use calimero_server_primitives::admin::SetContextVisibilityApiRequest; +use reqwest::StatusCode; +use tracing::{error, info}; + +use super::{parse_context_id, parse_group_id}; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiError, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, context_id_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let context_id = match parse_context_id(&context_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let mode = match req.mode.as_str() { + "open" => VisibilityMode::Open, + "restricted" => VisibilityMode::Restricted, + _ => { + return ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "mode must be 'open' or 'restricted'".into(), + } + .into_response() + } + }; + + info!(group_id=%group_id_str, context_id=%context_id_str, ?mode, "Setting context visibility"); + + let result = state + .ctx_client + .set_context_visibility(SetContextVisibilityRequest { + group_id, + context_id, + mode, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, context_id=%context_id_str, "Context visibility updated"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, context_id=%context_id_str, error=?err, "Failed to set context visibility"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_default_capabilities.rs b/crates/server/src/admin/handlers/groups/set_default_capabilities.rs new file mode 100644 index 0000000000..8529c2a66a --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_default_capabilities.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::SetDefaultCapabilitiesRequest; +use calimero_server_primitives::admin::SetDefaultCapabilitiesApiRequest; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, default_capabilities=req.default_capabilities, "Setting default capabilities"); + + let result = state + .ctx_client + .set_default_capabilities(SetDefaultCapabilitiesRequest { + group_id, + default_capabilities: req.default_capabilities, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Default capabilities updated"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to set default capabilities"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_default_visibility.rs b/crates/server/src/admin/handlers/groups/set_default_visibility.rs new file mode 100644 index 0000000000..8f4b84474d --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_default_visibility.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_config::VisibilityMode; +use calimero_context_primitives::group::SetDefaultVisibilityRequest; +use calimero_server_primitives::admin::SetDefaultVisibilityApiRequest; +use reqwest::StatusCode; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiError, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let mode = match req.default_visibility.as_str() { + "open" => VisibilityMode::Open, + "restricted" => VisibilityMode::Restricted, + _ => { + return ApiError { + status_code: StatusCode::BAD_REQUEST, + message: "default_visibility must be 'open' or 'restricted'".into(), + } + .into_response() + } + }; + + info!(group_id=%group_id_str, ?mode, "Setting default visibility"); + + let result = state + .ctx_client + .set_default_visibility(SetDefaultVisibilityRequest { + group_id, + default_visibility: mode, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Default visibility updated"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to set default visibility"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_group_alias.rs b/crates/server/src/admin/handlers/groups/set_group_alias.rs new file mode 100644 index 0000000000..2cbbdecd8a --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_group_alias.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::SetGroupAliasRequest; +use calimero_server_primitives::admin::SetGroupAliasApiRequest; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, alias=%req.alias, "Setting group alias"); + + let result = state + .ctx_client + .set_group_alias(SetGroupAliasRequest { + group_id, + alias: req.alias, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Group alias set"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to set group alias"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_member_alias.rs b/crates/server/src/admin/handlers/groups/set_member_alias.rs new file mode 100644 index 0000000000..44458f2759 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_member_alias.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::SetMemberAliasRequest; +use calimero_server_primitives::admin::SetMemberAliasApiRequest; +use tracing::{error, info}; + +use super::{parse_group_id, parse_identity}; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, identity_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let member = match parse_identity(&identity_str) { + Ok(pk) => pk, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, identity=%identity_str, alias=%req.alias, "Setting member alias"); + + let result = state + .ctx_client + .set_member_alias(SetMemberAliasRequest { + group_id, + member, + alias: req.alias, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, identity=%identity_str, "Member alias set"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, identity=%identity_str, error=?err, "Failed to set member alias"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/set_member_capabilities.rs b/crates/server/src/admin/handlers/groups/set_member_capabilities.rs new file mode 100644 index 0000000000..1af03b494b --- /dev/null +++ b/crates/server/src/admin/handlers/groups/set_member_capabilities.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::SetMemberCapabilitiesRequest; +use calimero_server_primitives::admin::SetMemberCapabilitiesApiRequest; +use tracing::{error, info}; + +use super::{parse_group_id, parse_identity}; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, identity_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let member = match parse_identity(&identity_str) { + Ok(pk) => pk, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, identity=%identity_str, capabilities=req.capabilities, "Setting member capabilities"); + + let result = state + .ctx_client + .set_member_capabilities(SetMemberCapabilitiesRequest { + group_id, + member, + capabilities: req.capabilities, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, identity=%identity_str, "Member capabilities updated"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, identity=%identity_str, error=?err, "Failed to set member capabilities"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/sync_group.rs b/crates/server/src/admin/handlers/groups/sync_group.rs new file mode 100644 index 0000000000..0aef60150c --- /dev/null +++ b/crates/server/src/admin/handlers/groups/sync_group.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::SyncGroupRequest; +use calimero_server_primitives::admin::{ + SyncGroupApiRequest, SyncGroupApiResponse, SyncGroupApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Syncing group state from contract"); + + let result = state + .ctx_client + .sync_group(SyncGroupRequest { + group_id, + requester: req.requester, + protocol: req.protocol, + network_id: req.network_id, + contract_id: req.contract_id, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + info!(group_id=%group_id_str, "Group state synced successfully"); + ApiResponse { + payload: SyncGroupApiResponse { + data: SyncGroupApiResponseData { + group_id: hex::encode(resp.group_id.to_bytes()), + app_key: hex::encode(resp.app_key), + target_application_id: resp.target_application_id, + member_count: resp.member_count, + context_count: resp.context_count, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to sync group state"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/update_group_settings.rs b/crates/server/src/admin/handlers/groups/update_group_settings.rs new file mode 100644 index 0000000000..5ae82867af --- /dev/null +++ b/crates/server/src/admin/handlers/groups/update_group_settings.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::UpdateGroupSettingsRequest; +use calimero_server_primitives::admin::UpdateGroupSettingsApiRequest; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, "Updating group settings"); + + let result = state + .ctx_client + .update_group_settings(UpdateGroupSettingsRequest { + group_id, + requester: req.requester, + upgrade_policy: req.upgrade_policy, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, "Group settings updated successfully"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to update group settings"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/update_member_role.rs b/crates/server/src/admin/handlers/groups/update_member_role.rs new file mode 100644 index 0000000000..ce283a38e3 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/update_member_role.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::UpdateMemberRoleRequest; +use calimero_server_primitives::admin::UpdateMemberRoleApiRequest; +use tracing::{error, info}; + +use super::{parse_group_id, parse_identity}; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse, Empty}; +use crate::AdminState; + +pub async fn handler( + Path((group_id_str, identity_str)): Path<(String, String)>, + Extension(state): Extension>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + let identity = match parse_identity(&identity_str) { + Ok(pk) => pk, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, identity=%identity_str, "Updating member role"); + + let result = state + .ctx_client + .update_member_role(UpdateMemberRoleRequest { + group_id, + identity, + new_role: req.role, + requester: req.requester, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(()) => { + info!(group_id=%group_id_str, identity=%identity_str, "Member role updated successfully"); + ApiResponse { payload: Empty }.into_response() + } + Err(err) => { + error!(group_id=%group_id_str, identity=%identity_str, error=?err, "Failed to update member role"); + err.into_response() + } + } +} diff --git a/crates/server/src/admin/handlers/groups/upgrade_group.rs b/crates/server/src/admin/handlers/groups/upgrade_group.rs new file mode 100644 index 0000000000..7479d356b5 --- /dev/null +++ b/crates/server/src/admin/handlers/groups/upgrade_group.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::Extension; +use calimero_context_primitives::group::{GroupUpgradeStatus, UpgradeGroupRequest}; +use calimero_context_primitives::messages::MigrationParams; +use calimero_server_primitives::admin::{ + UpgradeGroupApiRequest, UpgradeGroupApiResponse, UpgradeGroupApiResponseData, +}; +use tracing::{error, info}; + +use super::parse_group_id; +use crate::admin::handlers::validation::ValidatedJson; +use crate::admin::service::{parse_api_error, ApiResponse}; +use crate::auth::AuthenticatedKey; +use crate::AdminState; + +pub async fn handler( + Path(group_id_str): Path, + Extension(state): Extension>, + auth_key: Option>, + ValidatedJson(req): ValidatedJson, +) -> impl IntoResponse { + let group_id = match parse_group_id(&group_id_str) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + + info!(group_id=%group_id_str, %req.target_application_id, "Initiating group upgrade"); + + // Prefer the authenticated identity over the caller-supplied requester to + // prevent authorization bypass via a spoofed public key in the request body. + let requester = auth_key.map(|Extension(k)| k.0).or(req.requester); + + let migration = req.migrate_method.map(|method| MigrationParams { method }); + + let result = state + .ctx_client + .upgrade_group(UpgradeGroupRequest { + group_id, + target_application_id: req.target_application_id, + requester, + migration, + }) + .await + .map_err(parse_api_error); + + match result { + Ok(resp) => { + let (status_str, total, completed, failed) = format_status(&resp.status); + info!(group_id=%group_id_str, %status_str, "Group upgrade initiated"); + ApiResponse { + payload: UpgradeGroupApiResponse { + data: UpgradeGroupApiResponseData { + group_id: hex::encode(resp.group_id.to_bytes()), + status: status_str, + total, + completed, + failed, + }, + }, + } + .into_response() + } + Err(err) => { + error!(group_id=%group_id_str, error=?err, "Failed to initiate group upgrade"); + err.into_response() + } + } +} + +pub fn format_status( + status: &GroupUpgradeStatus, +) -> (String, Option, Option, Option) { + match status { + GroupUpgradeStatus::InProgress { + total, + completed, + failed, + } => ( + "in_progress".to_owned(), + Some(*total), + Some(*completed), + Some(*failed), + ), + GroupUpgradeStatus::Completed { .. } => ("completed".to_owned(), None, None, None), + } +} diff --git a/crates/server/src/admin/service.rs b/crates/server/src/admin/service.rs index fcea000705..dad5d59b2f 100644 --- a/crates/server/src/admin/service.rs +++ b/crates/server/src/admin/service.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::body::Body; use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode, Uri}; use axum::response::IntoResponse; -use axum::routing::{get, post, put}; +use axum::routing::{get, patch, post, put}; use axum::{Extension, Router}; use eyre::Report; use rust_embed::{EmbeddedFile, RustEmbed}; @@ -24,17 +24,18 @@ use super::handlers::proposals::{ get_proposal_approvers_handler, get_proposal_handler, get_proposals_handler, get_proxy_contract_handler, }; -use super::handlers::{alias, blob, tee}; +use super::handlers::{alias, blob, groups, tee}; use super::storage::ssl::get_ssl; use crate::admin::handlers::applications::{ get_application, install_application, install_dev_application, list_applications, uninstall_application, }; use crate::admin::handlers::context::{ - create_context, delete_context, get_context, get_context_identities, get_context_ids, - get_context_storage, get_contexts_for_application, get_contexts_with_executors_for_application, - invite_specialized_node, invite_to_context, invite_to_context_open_invitation, join_context, - join_context_open_invitation, sync, update_context_application, + create_context, delete_context, get_context, get_context_group, get_context_identities, + get_context_ids, get_context_storage, get_contexts_for_application, + get_contexts_with_executors_for_application, invite_specialized_node, invite_to_context, + invite_to_context_open_invitation, join_context, join_context_open_invitation, sync, + update_context_application, }; use crate::admin::handlers::identity::generate_context_identity; use crate::admin::handlers::packages::{get_latest_version, list_packages, list_versions}; @@ -147,6 +148,10 @@ pub(crate) fn setup( "/contexts/:context_id/identities-owned", get(get_context_identities::handler), ) + .route( + "/contexts/:context_id/group", + get(get_context_group::handler), + ) .route( "/contexts/:context_id/capabilities/grant", post(grant_capabilities::handler), @@ -217,6 +222,100 @@ pub(crate) fn setup( .head(blob::info_handler) .delete(blob::delete_handler), ) + // Group management + .route( + "/groups", + get(groups::list_all_groups::handler).post(groups::create_group::handler), + ) + .route( + "/groups/:group_id", + get(groups::get_group_info::handler) + .patch(groups::update_group_settings::handler) + .delete(groups::delete_group::handler), + ) + .route( + "/groups/:group_id/contexts", + get(groups::list_group_contexts::handler), + ) + .route( + "/groups/:group_id/members", + get(groups::list_group_members::handler).post(groups::add_group_members::handler), + ) + .route( + "/groups/:group_id/members/remove", + post(groups::remove_group_members::handler), + ) + .route( + "/groups/:group_id/members/:identity/role", + put(groups::update_member_role::handler), + ) + .route( + "/groups/:group_id/alias", + put(groups::set_group_alias::handler), + ) + .route( + "/groups/:group_id/members/:identity/alias", + put(groups::set_member_alias::handler), + ) + .route( + "/groups/:group_id/contexts/:context_id/remove", + post(groups::detach_context_from_group::handler), + ) + .route( + "/groups/:group_id/upgrade", + post(groups::upgrade_group::handler), + ) + .route( + "/groups/:group_id/upgrade/status", + get(groups::get_group_upgrade_status::handler), + ) + .route( + "/groups/:group_id/upgrade/retry", + post(groups::retry_group_upgrade::handler), + ) + .route( + "/groups/:group_id/signing-key", + post(groups::register_signing_key::handler), + ) + .route( + "/groups/:group_id/sync", + post(groups::sync_group::handler), + ) + .route( + "/groups/:group_id/invite", + post(groups::create_group_invitation::handler), + ) + .route( + "/groups/:group_id/join-context", + post(groups::join_group_context::handler), + ) + .route( + "/groups/:group_id/members/:identity/capabilities", + get(groups::get_member_capabilities::handler) + .put(groups::set_member_capabilities::handler), + ) + .route( + "/groups/:group_id/settings/default-capabilities", + put(groups::set_default_capabilities::handler), + ) + .route( + "/groups/:group_id/settings/default-visibility", + put(groups::set_default_visibility::handler), + ) + .route( + "/groups/:group_id/contexts/:context_id/visibility", + get(groups::get_context_visibility::handler) + .put(groups::set_context_visibility::handler), + ) + .route( + "/groups/:group_id/contexts/:context_id/allowlist", + get(groups::get_context_allowlist::handler) + .post(groups::manage_context_allowlist::handler), + ) + .route( + "/groups/join", + post(groups::join_group::handler), + ) // Alias management .nest("/alias", alias::service()) .layer(Extension(Arc::clone(&shared_state))) diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index eb40b91806..cb6e0222ee 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -12,10 +12,18 @@ use futures_util::FutureExt; use mero_auth::embedded::{build_app, default_config, EmbeddedAuthApp}; use mero_auth::AuthService; use tower::{Layer, Service}; -use tracing::info; +use tracing::{info, warn}; use crate::config::ServerConfig; +/// The authenticated requester's public key, injected into request extensions +/// by [`AuthGuardService`] after token verification. +/// +/// Handlers extract this via `Extension(AuthenticatedKey(pk))` and use it as +/// the effective requester instead of trusting the value from the request body. +#[derive(Clone, Debug)] +pub struct AuthenticatedKey(pub calimero_primitives::identity::PublicKey); + /// Wrapper around the embedded authentication application, keeping the router and shared state. pub struct BundledAuth { app: EmbeddedAuthApp, @@ -101,15 +109,38 @@ where fn call(&mut self, req: Request) -> Self::Future { let mut inner = self.inner.clone(); let service = Arc::clone(&self.service); - let (parts, body) = req.into_parts(); + let (mut parts, body) = req.into_parts(); let method = parts.method.clone(); let headers = parts.headers.clone(); async move { if method != Method::OPTIONS { - if service.verify_token_from_headers(&headers).await.is_err() { - let resp = StatusCode::UNAUTHORIZED.into_response(); - return Ok(resp); + let auth_response = match service.verify_token_from_headers(&headers).await { + Ok(resp) => resp, + Err(_) => { + return Ok(StatusCode::UNAUTHORIZED.into_response()); + } + }; + + // Attempt to resolve the authenticated public key and inject it so + // handlers can use it as the effective requester without trusting the + // caller-supplied value. + match service.get_key_public_key(&auth_response.key_id).await { + Ok(Some(pk_hex)) => { + use std::str::FromStr as _; + match calimero_primitives::identity::PublicKey::from_str(&pk_hex) { + Ok(pk) => { + parts.extensions.insert(AuthenticatedKey(pk)); + } + Err(err) => { + warn!(key_id=%auth_response.key_id, %err, "auth key_id public_key is not a valid PublicKey; skipping extension injection"); + } + } + } + Ok(None) => {} + Err(err) => { + warn!(key_id=%auth_response.key_id, %err, "failed to look up public key for auth key_id"); + } } } diff --git a/crates/storage/src/tests/crdt.rs b/crates/storage/src/tests/crdt.rs index 640cbbd7b9..1363c663bc 100644 --- a/crates/storage/src/tests/crdt.rs +++ b/crates/storage/src/tests/crdt.rs @@ -8,6 +8,8 @@ //! - Action merging //! - Edge cases +use serial_test::serial; + use super::common::{Page, Paragraph}; use crate::action::Action; use crate::address::Id; @@ -28,6 +30,7 @@ const ONE_SEC_NANOS: u64 = 1_000_000_000; // ============================================================ #[test] +#[serial] fn lww_newer_update_wins() { super::common::register_test_merge_functions(); let mut page = Page::new_from_element("Version 1", Element::root()); @@ -49,6 +52,7 @@ fn lww_newer_update_wins() { } #[test] +#[serial] fn lww_newer_overwrites_older() { super::common::register_test_merge_functions(); let mut page = Page::new_from_element("Version 1", Element::root()); @@ -69,6 +73,7 @@ fn lww_newer_overwrites_older() { } #[test] +#[serial] fn lww_concurrent_updates_deterministic() { super::common::register_test_merge_functions(); let mut page = Page::new_from_element("Initial", Element::root()); @@ -224,6 +229,7 @@ fn delete_vs_update_conflict() { } #[test] +#[serial] fn update_vs_delete_conflict() { super::common::register_test_merge_functions(); let mut page = Page::new_from_element("Test Page", Element::root()); @@ -265,6 +271,7 @@ fn update_vs_delete_conflict() { // ============================================================ #[test] +#[serial] fn concurrent_updates_different_entities() { super::common::register_test_merge_functions(); // Test that concurrent updates to different entities both succeed @@ -298,6 +305,7 @@ fn concurrent_adds_to_collection() { } #[test] +#[serial] fn concurrent_update_same_entity_different_fields() { super::common::register_test_merge_functions(); // Create entity with multiple fields @@ -344,6 +352,7 @@ fn concurrent_update_same_entity_different_fields() { // ============================================================ #[test] +#[serial] fn actions_idempotent() { super::common::register_test_merge_functions(); let page = Page::new_from_element("Test", Element::root()); @@ -420,6 +429,7 @@ fn delete_prevents_old_add() { // ============================================================ #[test] +#[serial] fn same_timestamp_lww_behavior() { super::common::register_test_merge_functions(); // With actual API, timestamps are always increasing @@ -520,6 +530,7 @@ fn multiple_deletes_idempotent() { // ============================================================ #[test] +#[serial] fn many_sequential_updates() { super::common::register_test_merge_functions(); let mut page = Page::new_from_element("Version 0", Element::root()); @@ -549,6 +560,7 @@ fn many_sequential_updates() { } #[test] +#[serial] fn rapid_add_delete_cycles() { super::common::register_test_merge_functions(); // Test rapid add/delete cycles work correctly diff --git a/crates/store/src/db.rs b/crates/store/src/db.rs index dbf6f30030..67bb0b5144 100644 --- a/crates/store/src/db.rs +++ b/crates/store/src/db.rs @@ -26,6 +26,7 @@ pub enum Column { Application, Alias, Generic, + Group, } pub trait Database<'a>: Debug + Send + Sync + 'static { diff --git a/crates/store/src/iter.rs b/crates/store/src/iter.rs index 217c4d7948..5f85bcba69 100644 --- a/crates/store/src/iter.rs +++ b/crates/store/src/iter.rs @@ -115,19 +115,37 @@ where Report: From>, { pub fn seek(&mut self, key: K) -> EyreResult> { - let Some(key) = self.inner.seek(key.as_key().as_slice())? else { + let Some(mut raw_key) = self.inner.seek(key.as_key().as_slice())? else { return Ok(None); }; - Ok(Some(Structured::::try_into_key(key)?)) + // Advance past keys with mismatched sizes (different key type in same column) + loop { + match Structured::::try_into_key(raw_key) { + Ok(key) => return Ok(Some(key)), + Err(e) if Structured::::is_size_mismatch(&e) => { + let Some(next_key) = self.inner.next()? else { + return Ok(None); + }; + raw_key = next_key; + } + Err(e) => return Err(e.into()), + } + } } pub fn next(&mut self) -> EyreResult> { - let Some(key) = self.inner.next()? else { - return Ok(None); - }; + loop { + let Some(key) = self.inner.next()? else { + return Ok(None); + }; - Ok(Some(Structured::::try_into_key(key)?)) + match Structured::::try_into_key(key) { + Ok(key) => return Ok(Some(key)), + Err(e) if Structured::::is_size_mismatch(&e) => continue, + Err(e) => return Err(e.into()), + } + } } } @@ -242,12 +260,17 @@ where type Item = EyreResult; fn next(&mut self) -> Option { - if !self.iter.done { + while !self.iter.done { match self.iter.inner.next() { Ok(Some(key)) => { // safety: key only needs to live as long as the iterator, not it's reference let key = unsafe { transmute::, Slice<'_>>(key) }; - return Some(K::try_into_key(key).map_err(Into::into)); + match K::try_into_key(key) { + Ok(key) => return Some(Ok(key)), + // Skip keys with mismatched sizes (different key type in same column) + Err(e) if K::is_size_mismatch(&e) => continue, + Err(e) => return Some(Err(e.into())), + } } Err(e) => { // Fuse iterator on error to prevent continued iteration after failure @@ -295,28 +318,34 @@ where fn next(&mut self) -> Option { let key = 'key: { - let key = 'found: { - if !self.iter.done { - match self.iter.inner.next() { - Ok(Some(key)) => break 'found key, - Err(e) => { - // Fuse iterator on error to prevent continued iteration after failure - self.iter.done = true; - break 'key Err(e); + let key = loop { + if self.iter.done { + return None; + } + + match self.iter.inner.next() { + Ok(Some(raw_key)) => { + // safety: key only needs to live as long as the iterator, not it's reference + let raw_key = unsafe { transmute::, Slice<'_>>(raw_key) }; + match K::try_into_key(raw_key) { + Ok(key) => break key, + // Skip keys with mismatched sizes (different key type in same column) + Err(e) if K::is_size_mismatch(&e) => continue, + Err(e) => break 'key Err(e.into()), } - _ => {} } - - self.iter.done = true; + Err(e) => { + self.iter.done = true; + break 'key Err(e); + } + Ok(None) => { + self.iter.done = true; + return None; + } } - - return None; }; - // safety: key only needs to live as long as the iterator, not it's reference - let key = unsafe { transmute::, Slice<'_>>(key) }; - - K::try_into_key(key).map_err(Into::into) + Ok(key) }; let value = 'value: { @@ -360,6 +389,10 @@ pub trait TryIntoKey<'a>: Sealed { type Error; fn try_into_key(key: Key<'a>) -> Result; + + /// Returns true if the error is a size mismatch, meaning the key belongs to + /// a different type in the same column and should be skipped during iteration. + fn is_size_mismatch(error: &Self::Error) -> bool; } pub trait TryIntoValue<'a>: Sealed { @@ -388,6 +421,10 @@ impl<'a, K: FromKeyParts> TryIntoKey<'a> for Structured { K::try_from_parts(key).map_err(IterError::Structured) } + + fn is_size_mismatch(error: &Self::Error) -> bool { + matches!(error, IterError::SizeMismatch) + } } impl<'a, V, C: Codec<'a, V>> TryIntoValue<'a> for Structured<(V, C)> { @@ -407,6 +444,10 @@ impl<'a> TryIntoKey<'a> for Unstructured { fn try_into_key(key: Key<'a>) -> Result { Ok(key) } + + fn is_size_mismatch(_error: &Self::Error) -> bool { + match *_error {} + } } impl<'a> TryIntoValue<'a> for Unstructured { diff --git a/crates/store/src/key.rs b/crates/store/src/key.rs index b2618e2d7b..559cde5324 100644 --- a/crates/store/src/key.rs +++ b/crates/store/src/key.rs @@ -18,15 +18,30 @@ mod blobs; mod component; mod context; mod generic; +mod group; pub use alias::{Alias, Aliasable, StoreScopeCompat}; pub use application::ApplicationMeta; pub use blobs::BlobMeta; +pub use calimero_primitives::context::GroupMemberRole; use component::KeyComponents; pub use context::{ ContextConfig, ContextDagDelta, ContextIdentity, ContextMeta, ContextPrivateState, ContextState, }; pub use generic::{Generic, FRAGMENT_SIZE, SCOPE_SIZE}; +pub use group::{ + ContextGroupRef, GroupAlias, GroupContextAlias, GroupContextAllowlist, GroupContextIndex, + GroupContextLastMigration, GroupContextLastMigrationValue, GroupContextVisibility, + GroupContextVisibilityValue, GroupDefaultCaps, GroupDefaultCapsValue, GroupDefaultVis, + GroupDefaultVisValue, GroupMember, GroupMemberAlias, GroupMemberCapability, + GroupMemberCapabilityValue, GroupMeta, GroupMetaValue, GroupSigningKey, GroupSigningKeyValue, + GroupUpgradeKey, GroupUpgradeStatus, GroupUpgradeValue, GROUP_ALIAS_PREFIX, + GROUP_CONTEXT_ALIAS_PREFIX, GROUP_CONTEXT_ALLOWLIST_PREFIX, GROUP_CONTEXT_INDEX_PREFIX, + GROUP_CONTEXT_LAST_MIGRATION_PREFIX, GROUP_CONTEXT_VISIBILITY_PREFIX, + GROUP_DEFAULT_CAPS_PREFIX, GROUP_DEFAULT_VIS_PREFIX, GROUP_MEMBER_ALIAS_PREFIX, + GROUP_MEMBER_CAPABILITY_PREFIX, GROUP_MEMBER_PREFIX, GROUP_META_PREFIX, + GROUP_SIGNING_KEY_PREFIX, GROUP_UPGRADE_PREFIX, +}; pub struct Key(GenericArray); diff --git a/crates/store/src/key/group.rs b/crates/store/src/key/group.rs new file mode 100644 index 0000000000..4c099833e6 --- /dev/null +++ b/crates/store/src/key/group.rs @@ -0,0 +1,1218 @@ +use core::convert::Infallible; +use core::fmt::{self, Debug, Formatter}; + +#[cfg(feature = "borsh")] +use borsh::{BorshDeserialize, BorshSerialize}; +use calimero_primitives::application::ApplicationId; +use calimero_primitives::context::{ContextId as PrimitiveContextId, UpgradePolicy}; +use calimero_primitives::identity::PublicKey as PrimitivePublicKey; +use generic_array::sequence::Concat; +use generic_array::typenum::{U1, U32}; +use generic_array::GenericArray; + +use crate::db::Column; +use crate::key::component::KeyComponent; +use crate::key::{AsKeyParts, FromKeyParts, Key}; + +pub const GROUP_META_PREFIX: u8 = 0x20; +pub const GROUP_MEMBER_PREFIX: u8 = 0x21; +pub const GROUP_CONTEXT_INDEX_PREFIX: u8 = 0x22; +const CONTEXT_GROUP_REF_PREFIX: u8 = 0x23; +pub const GROUP_UPGRADE_PREFIX: u8 = 0x24; +pub const GROUP_SIGNING_KEY_PREFIX: u8 = 0x25; +pub const GROUP_MEMBER_CAPABILITY_PREFIX: u8 = 0x26; +pub const GROUP_CONTEXT_VISIBILITY_PREFIX: u8 = 0x27; +pub const GROUP_CONTEXT_ALLOWLIST_PREFIX: u8 = 0x28; +pub const GROUP_DEFAULT_CAPS_PREFIX: u8 = 0x29; +pub const GROUP_DEFAULT_VIS_PREFIX: u8 = 0x2A; +pub const GROUP_CONTEXT_LAST_MIGRATION_PREFIX: u8 = 0x2B; + +#[derive(Clone, Copy, Debug)] +pub struct GroupPrefix; + +impl KeyComponent for GroupPrefix { + type LEN = U1; +} + +#[derive(Clone, Copy, Debug)] +pub struct GroupIdComponent; + +impl KeyComponent for GroupIdComponent { + type LEN = U32; +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMeta(Key<(GroupPrefix, GroupIdComponent)>); + +impl GroupMeta { + #[must_use] + pub fn new(group_id: [u8; 32]) -> Self { + Self(Key( + GenericArray::from([GROUP_META_PREFIX]).concat(GenericArray::from(group_id)) + )) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id + } +} + +impl AsKeyParts for GroupMeta { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupMeta { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupMeta { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupMeta") + .field("group_id", &self.group_id()) + .finish() + } +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMember(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupMember { + #[must_use] + pub fn new(group_id: [u8; 32], identity: PrimitivePublicKey) -> Self { + Self(Key(GenericArray::from([GROUP_MEMBER_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*identity)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn identity(&self) -> PrimitivePublicKey { + let mut pk = [0; 32]; + pk.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + pk.into() + } +} + +impl AsKeyParts for GroupMember { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupMember { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupMember { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupMember") + .field("group_id", &self.group_id()) + .field("identity", &self.identity()) + .finish() + } +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextIndex(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupContextIndex { + #[must_use] + pub fn new(group_id: [u8; 32], context_id: PrimitiveContextId) -> Self { + Self(Key(GenericArray::from([GROUP_CONTEXT_INDEX_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*context_id)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + id.into() + } +} + +impl AsKeyParts for GroupContextIndex { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupContextIndex { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupContextIndex { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupContextIndex") + .field("group_id", &self.group_id()) + .field("context_id", &self.context_id()) + .finish() + } +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct ContextGroupRef(Key<(GroupPrefix, GroupIdComponent)>); + +impl ContextGroupRef { + #[must_use] + pub fn new(context_id: PrimitiveContextId) -> Self { + Self(Key( + GenericArray::from([CONTEXT_GROUP_REF_PREFIX]).concat(GenericArray::from(*context_id)) + )) + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id.into() + } +} + +impl AsKeyParts for ContextGroupRef { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for ContextGroupRef { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for ContextGroupRef { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ContextGroupRef") + .field("context_id", &self.context_id()) + .finish() + } +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupUpgradeKey(Key<(GroupPrefix, GroupIdComponent)>); + +impl GroupUpgradeKey { + #[must_use] + pub fn new(group_id: [u8; 32]) -> Self { + Self(Key( + GenericArray::from([GROUP_UPGRADE_PREFIX]).concat(GenericArray::from(group_id)) + )) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id + } +} + +impl AsKeyParts for GroupUpgradeKey { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupUpgradeKey { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupUpgradeKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupUpgradeKey") + .field("group_id", &self.group_id()) + .finish() + } +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupSigningKey(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupSigningKey { + #[must_use] + pub fn new(group_id: [u8; 32], public_key: PrimitivePublicKey) -> Self { + Self(Key(GenericArray::from([GROUP_SIGNING_KEY_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*public_key)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn public_key(&self) -> PrimitivePublicKey { + let mut pk = [0; 32]; + pk.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + pk.into() + } +} + +impl AsKeyParts for GroupSigningKey { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupSigningKey { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupSigningKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupSigningKey") + .field("group_id", &self.group_id()) + .field("public_key", &self.public_key()) + .finish() + } +} + +/// Stored against [`GroupSigningKey`]. Holds the private key for a group member. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupSigningKeyValue { + pub private_key: [u8; 32], +} + +// --------------------------------------------------------------------------- +// Group permission key types +// --------------------------------------------------------------------------- + +/// Key for per-member capability bitfield: prefix + group_id + member_pk. +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMemberCapability(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupMemberCapability { + #[must_use] + pub fn new(group_id: [u8; 32], identity: PrimitivePublicKey) -> Self { + Self(Key(GenericArray::from([GROUP_MEMBER_CAPABILITY_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*identity)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn identity(&self) -> PrimitivePublicKey { + let mut pk = [0; 32]; + pk.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + pk.into() + } +} + +impl AsKeyParts for GroupMemberCapability { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupMemberCapability { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupMemberCapability { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupMemberCapability") + .field("group_id", &self.group_id()) + .field("identity", &self.identity()) + .finish() + } +} + +/// Value for [`GroupMemberCapability`]. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMemberCapabilityValue { + pub capabilities: u32, +} + +/// Key for context visibility info: prefix + group_id + context_id. +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextVisibility(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupContextVisibility { + #[must_use] + pub fn new(group_id: [u8; 32], context_id: PrimitiveContextId) -> Self { + Self(Key(GenericArray::from([GROUP_CONTEXT_VISIBILITY_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*context_id)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + id.into() + } +} + +impl AsKeyParts for GroupContextVisibility { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupContextVisibility { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupContextVisibility { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupContextVisibility") + .field("group_id", &self.group_id()) + .field("context_id", &self.context_id()) + .finish() + } +} + +/// Value for [`GroupContextVisibility`]. +/// `mode`: 0 = Open, 1 = Restricted. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextVisibilityValue { + pub mode: u8, + pub creator: [u8; 32], +} + +/// Key for context allowlist entry: prefix + group_id + context_id + member_pk. +/// Uses a 97-byte key (1 + 32 + 32 + 32). +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextAllowlist( + Key<( + GroupPrefix, + GroupIdComponent, + GroupIdComponent, + GroupIdComponent, + )>, +); + +impl GroupContextAllowlist { + #[must_use] + pub fn new( + group_id: [u8; 32], + context_id: PrimitiveContextId, + member: PrimitivePublicKey, + ) -> Self { + Self(Key(GenericArray::from([GROUP_CONTEXT_ALLOWLIST_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*context_id)) + .concat(GenericArray::from(*member)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 97]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 97]>::as_ref(&self.0)[33..65]); + id.into() + } + + #[must_use] + pub fn member(&self) -> PrimitivePublicKey { + let mut pk = [0; 32]; + pk.copy_from_slice(&AsRef::<[_; 97]>::as_ref(&self.0)[65..]); + pk.into() + } +} + +impl AsKeyParts for GroupContextAllowlist { + type Components = ( + GroupPrefix, + GroupIdComponent, + GroupIdComponent, + GroupIdComponent, + ); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupContextAllowlist { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupContextAllowlist { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupContextAllowlist") + .field("group_id", &self.group_id()) + .field("context_id", &self.context_id()) + .field("member", &self.member()) + .finish() + } +} + +/// Key for group default capabilities: prefix + group_id. +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupDefaultCaps(Key<(GroupPrefix, GroupIdComponent)>); + +impl GroupDefaultCaps { + #[must_use] + pub fn new(group_id: [u8; 32]) -> Self { + Self(Key( + GenericArray::from([GROUP_DEFAULT_CAPS_PREFIX]).concat(GenericArray::from(group_id)) + )) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id + } +} + +impl AsKeyParts for GroupDefaultCaps { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupDefaultCaps { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupDefaultCaps { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupDefaultCaps") + .field("group_id", &self.group_id()) + .finish() + } +} + +/// Value for [`GroupDefaultCaps`]. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupDefaultCapsValue { + pub capabilities: u32, +} + +/// Key for group default visibility: prefix + group_id. +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupDefaultVis(Key<(GroupPrefix, GroupIdComponent)>); + +impl GroupDefaultVis { + #[must_use] + pub fn new(group_id: [u8; 32]) -> Self { + Self(Key( + GenericArray::from([GROUP_DEFAULT_VIS_PREFIX]).concat(GenericArray::from(group_id)) + )) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id + } +} + +impl AsKeyParts for GroupDefaultVis { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupDefaultVis { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupDefaultVis { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupDefaultVis") + .field("group_id", &self.group_id()) + .finish() + } +} + +/// Value for [`GroupDefaultVis`]. `mode`: 0 = Open, 1 = Restricted. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupDefaultVisValue { + pub mode: u8, +} + +/// Key for tracking the last migration applied to a specific context in a group: +/// prefix + group_id + context_id. +/// +/// The value is the migration method name that was last successfully applied. +/// Used by `maybe_lazy_upgrade` to avoid re-running a migration that already +/// completed for this context. +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextLastMigration(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupContextLastMigration { + #[must_use] + pub fn new(group_id: [u8; 32], context_id: PrimitiveContextId) -> Self { + Self(Key(GenericArray::from([ + GROUP_CONTEXT_LAST_MIGRATION_PREFIX, + ]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*context_id)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..65]); + id.into() + } +} + +impl AsKeyParts for GroupContextLastMigration { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupContextLastMigration { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupContextLastMigration { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupContextLastMigration") + .field("group_id", &self.group_id()) + .field("context_id", &self.context_id()) + .finish() + } +} + +/// Value for [`GroupContextLastMigration`] — the migration method name that was last applied. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextLastMigrationValue { + pub method: String, +} + +pub const GROUP_CONTEXT_ALIAS_PREFIX: u8 = 0x2C; + +/// Stores the human-readable alias for a context within a group. +/// Key: prefix (1 byte) + group_id (32 bytes) + context_id (32 bytes) → alias string +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupContextAlias(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupContextAlias { + #[must_use] + pub fn new(group_id: [u8; 32], context_id: PrimitiveContextId) -> Self { + Self(Key(GenericArray::from([GROUP_CONTEXT_ALIAS_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*context_id)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn context_id(&self) -> PrimitiveContextId { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..65]); + id.into() + } +} + +impl AsKeyParts for GroupContextAlias { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupContextAlias { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupContextAlias { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupContextAlias") + .field("group_id", &self.group_id()) + .field("context_id", &self.context_id()) + .finish() + } +} + +pub const GROUP_MEMBER_ALIAS_PREFIX: u8 = 0x2D; + +/// Stores a human-readable alias for a group member scoped to a specific group. +/// Key: prefix (1 byte) + group_id (32 bytes) + member_pk (32 bytes) → alias String +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMemberAlias(Key<(GroupPrefix, GroupIdComponent, GroupIdComponent)>); + +impl GroupMemberAlias { + #[must_use] + pub fn new(group_id: [u8; 32], member: PrimitivePublicKey) -> Self { + Self(Key(GenericArray::from([GROUP_MEMBER_ALIAS_PREFIX]) + .concat(GenericArray::from(group_id)) + .concat(GenericArray::from(*member)))) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[1..33]); + id + } + + #[must_use] + pub fn member(&self) -> PrimitivePublicKey { + let mut pk = [0; 32]; + pk.copy_from_slice(&AsRef::<[_; 65]>::as_ref(&self.0)[33..]); + pk.into() + } +} + +impl AsKeyParts for GroupMemberAlias { + type Components = (GroupPrefix, GroupIdComponent, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupMemberAlias { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupMemberAlias { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupMemberAlias") + .field("group_id", &self.group_id()) + .field("member", &self.member()) + .finish() + } +} + +pub const GROUP_ALIAS_PREFIX: u8 = 0x2E; + +/// Stores a human-readable alias for the group itself. +/// Key: prefix (1 byte) + group_id (32 bytes) → alias String +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupAlias(Key<(GroupPrefix, GroupIdComponent)>); + +impl GroupAlias { + #[must_use] + pub fn new(group_id: [u8; 32]) -> Self { + Self(Key( + GenericArray::from([GROUP_ALIAS_PREFIX]).concat(GenericArray::from(group_id)) + )) + } + + #[must_use] + pub fn group_id(&self) -> [u8; 32] { + let mut id = [0; 32]; + id.copy_from_slice(&AsRef::<[_; 33]>::as_ref(&self.0)[1..]); + id + } +} + +impl AsKeyParts for GroupAlias { + type Components = (GroupPrefix, GroupIdComponent); + + fn column() -> Column { + Column::Group + } + + fn as_key(&self) -> &Key { + &self.0 + } +} + +impl FromKeyParts for GroupAlias { + type Error = Infallible; + + fn try_from_parts(parts: Key) -> Result { + Ok(Self(parts)) + } +} + +impl Debug for GroupAlias { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("GroupAlias") + .field("group_id", &self.group_id()) + .finish() + } +} + +/// Stored against [`GroupMeta`]. Captures the immutable + mutable metadata of a +/// context group. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupMetaValue { + pub app_key: [u8; 32], + pub target_application_id: ApplicationId, + pub upgrade_policy: UpgradePolicy, + pub created_at: u64, + pub admin_identity: PrimitivePublicKey, + pub migration: Option>, +} + +/// Tracks the progress of a group-wide upgrade operation. +/// Stored against [`GroupUpgradeKey`]. +/// +/// `ApplicationId` is stable across versions (`hash(package, signer_id)`), so +/// upgrades are tracked by semver version string from the local +/// `ApplicationMeta`, not by application id. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct GroupUpgradeValue { + /// Semver version of the application before the upgrade, read from the + /// current application's `ApplicationMeta.version`. + pub from_version: String, + /// Semver version of the target application, read from the target + /// application's `ApplicationMeta.version`. + pub to_version: String, + pub migration: Option>, + pub initiated_at: u64, + pub initiated_by: PrimitivePublicKey, + pub status: GroupUpgradeStatus, +} + +/// State machine for a group upgrade operation. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub enum GroupUpgradeStatus { + InProgress { + total: u32, + completed: u32, + failed: u32, + }, + Completed { + /// Unix timestamp when the last context was upgraded, or `None` for + /// `LazyOnAccess` upgrades where contexts upgrade individually on demand. + completed_at: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_meta_roundtrip() { + let id = [0xAB; 32]; + let key = GroupMeta::new(id); + assert_eq!(key.group_id(), id); + assert_eq!(key.as_key().as_bytes()[0], GROUP_META_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 33); + } + + #[test] + fn group_member_roundtrip() { + let gid = [0xCD; 32]; + let pk = PrimitivePublicKey::from([0xEF; 32]); + let key = GroupMember::new(gid, pk); + assert_eq!(key.group_id(), gid); + assert_eq!(key.identity(), pk); + assert_eq!(key.as_key().as_bytes()[0], GROUP_MEMBER_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 65); + } + + #[test] + fn group_context_index_roundtrip() { + let gid = [0x11; 32]; + let cid = PrimitiveContextId::from([0x22; 32]); + let key = GroupContextIndex::new(gid, cid); + assert_eq!(key.group_id(), gid); + assert_eq!(key.context_id(), cid); + assert_eq!(key.as_key().as_bytes()[0], GROUP_CONTEXT_INDEX_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 65); + } + + #[test] + fn context_group_ref_roundtrip() { + let cid = PrimitiveContextId::from([0x33; 32]); + let key = ContextGroupRef::new(cid); + assert_eq!(key.context_id(), cid); + assert_eq!(key.as_key().as_bytes()[0], CONTEXT_GROUP_REF_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 33); + } + + #[test] + fn group_upgrade_key_roundtrip() { + let gid = [0x44; 32]; + let key = GroupUpgradeKey::new(gid); + assert_eq!(key.group_id(), gid); + assert_eq!(key.as_key().as_bytes()[0], GROUP_UPGRADE_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 33); + } + + #[test] + fn group_signing_key_roundtrip() { + let gid = [0x55; 32]; + let pk = PrimitivePublicKey::from([0x66; 32]); + let key = GroupSigningKey::new(gid, pk); + assert_eq!(key.group_id(), gid); + assert_eq!(key.public_key(), pk); + assert_eq!(key.as_key().as_bytes()[0], GROUP_SIGNING_KEY_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 65); + } + + #[test] + fn distinct_prefixes() { + let prefixes = [ + GROUP_META_PREFIX, + GROUP_MEMBER_PREFIX, + GROUP_CONTEXT_INDEX_PREFIX, + CONTEXT_GROUP_REF_PREFIX, + GROUP_UPGRADE_PREFIX, + GROUP_SIGNING_KEY_PREFIX, + GROUP_MEMBER_CAPABILITY_PREFIX, + GROUP_CONTEXT_VISIBILITY_PREFIX, + GROUP_CONTEXT_ALLOWLIST_PREFIX, + GROUP_DEFAULT_CAPS_PREFIX, + GROUP_DEFAULT_VIS_PREFIX, + GROUP_CONTEXT_LAST_MIGRATION_PREFIX, + GROUP_MEMBER_ALIAS_PREFIX, + GROUP_ALIAS_PREFIX, + GROUP_CONTEXT_ALIAS_PREFIX, + ]; + for i in 0..prefixes.len() { + for j in (i + 1)..prefixes.len() { + assert_ne!( + prefixes[i], prefixes[j], + "prefix collision at indices {i} and {j}" + ); + } + } + } + + #[test] + fn group_member_alias_roundtrip() { + let gid = [0xDA; 32]; + let pk = PrimitivePublicKey::from([0xDB; 32]); + let key = GroupMemberAlias::new(gid, pk); + assert_eq!(key.group_id(), gid); + assert_eq!(key.member(), pk); + assert_eq!(key.as_key().as_bytes()[0], GROUP_MEMBER_ALIAS_PREFIX); + assert_eq!(key.as_key().as_bytes().len(), 65); + } + + #[cfg(feature = "borsh")] + mod value_roundtrips { + use borsh::{from_slice, to_vec}; + use calimero_primitives::application::ApplicationId; + use calimero_primitives::context::{GroupMemberRole, UpgradePolicy}; + use calimero_primitives::identity::PublicKey as PrimitivePublicKey; + + use super::super::{GroupMetaValue, GroupUpgradeStatus, GroupUpgradeValue}; + + #[test] + fn group_meta_value_roundtrip() { + let value = GroupMetaValue { + app_key: [0xAA; 32], + target_application_id: ApplicationId::from([0xBB; 32]), + upgrade_policy: UpgradePolicy::Automatic, + created_at: 1_700_000_000, + admin_identity: PrimitivePublicKey::from([0xCC; 32]), + migration: None, + }; + + let bytes = to_vec(&value).expect("serialize"); + let decoded: GroupMetaValue = from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.app_key, value.app_key); + assert_eq!(decoded.target_application_id, value.target_application_id); + assert_eq!(decoded.created_at, value.created_at); + assert_eq!(decoded.admin_identity, value.admin_identity); + assert!(matches!(decoded.upgrade_policy, UpgradePolicy::Automatic)); + } + + #[test] + fn group_meta_value_coordinated_policy_roundtrip() { + use core::time::Duration; + + let value = GroupMetaValue { + app_key: [0x11; 32], + target_application_id: ApplicationId::from([0x22; 32]), + upgrade_policy: UpgradePolicy::Coordinated { + deadline: Some(Duration::from_secs(3600)), + }, + created_at: 1_700_000_000, + admin_identity: PrimitivePublicKey::from([0x33; 32]), + migration: None, + }; + + let bytes = to_vec(&value).expect("serialize"); + let decoded: GroupMetaValue = from_slice(&bytes).expect("deserialize"); + + match decoded.upgrade_policy { + UpgradePolicy::Coordinated { deadline } => { + assert_eq!(deadline, Some(Duration::from_secs(3600))); + } + other => panic!("expected Coordinated, got {other:?}"), + } + } + + #[test] + fn group_member_role_roundtrip() { + for role in [GroupMemberRole::Admin, GroupMemberRole::Member] { + let bytes = to_vec(&role).expect("serialize"); + let decoded: GroupMemberRole = from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, role); + } + } + + #[test] + fn group_upgrade_value_in_progress_roundtrip() { + let value = GroupUpgradeValue { + from_version: "1.0.0".to_owned(), + to_version: "2.0.0".to_owned(), + migration: Some(vec![0xDE, 0xAD]), + initiated_at: 1_700_000_000, + initiated_by: PrimitivePublicKey::from([0x03; 32]), + status: GroupUpgradeStatus::InProgress { + total: 10, + completed: 3, + failed: 1, + }, + }; + + let bytes = to_vec(&value).expect("serialize"); + let decoded: GroupUpgradeValue = from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.from_version, "1.0.0"); + assert_eq!(decoded.to_version, "2.0.0"); + assert_eq!(decoded.migration, Some(vec![0xDE, 0xAD])); + assert_eq!(decoded.initiated_at, value.initiated_at); + assert_eq!(decoded.initiated_by, value.initiated_by); + match decoded.status { + GroupUpgradeStatus::InProgress { + total, + completed, + failed, + } => { + assert_eq!(total, 10); + assert_eq!(completed, 3); + assert_eq!(failed, 1); + } + other => panic!("expected InProgress, got {other:?}"), + } + } + + #[test] + fn group_upgrade_value_no_migration_roundtrip() { + let value = GroupUpgradeValue { + from_version: "3.0.0".to_owned(), + to_version: "4.0.0".to_owned(), + migration: None, + initiated_at: 1_700_000_000, + initiated_by: PrimitivePublicKey::from([0x06; 32]), + status: GroupUpgradeStatus::Completed { + completed_at: Some(1_700_001_000), + }, + }; + + let bytes = to_vec(&value).expect("serialize"); + let decoded: GroupUpgradeValue = from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.from_version, "3.0.0"); + assert_eq!(decoded.to_version, "4.0.0"); + assert_eq!(decoded.migration, None); + match decoded.status { + GroupUpgradeStatus::Completed { completed_at } => { + assert_eq!(completed_at, Some(1_700_001_000)); + } + other => panic!("expected Completed, got {other:?}"), + } + } + } +} diff --git a/crates/store/src/types.rs b/crates/store/src/types.rs index c24b4f072b..a06e6cab81 100644 --- a/crates/store/src/types.rs +++ b/crates/store/src/types.rs @@ -6,6 +6,7 @@ mod application; mod blobs; mod context; mod generic; +mod group; pub use application::ApplicationMeta; pub use blobs::BlobMeta; diff --git a/crates/store/src/types/group.rs b/crates/store/src/types/group.rs new file mode 100644 index 0000000000..2037660134 --- /dev/null +++ b/crates/store/src/types/group.rs @@ -0,0 +1,82 @@ +#![allow(single_use_lifetimes, reason = "borsh shenanigans")] + +use calimero_primitives::context::GroupMemberRole; + +use crate::entry::Borsh; +use crate::key; +use crate::types::PredefinedEntry; + +impl PredefinedEntry for key::GroupMeta { + type Codec = Borsh; + type DataType<'a> = key::GroupMetaValue; +} + +impl PredefinedEntry for key::GroupMember { + type Codec = Borsh; + type DataType<'a> = GroupMemberRole; +} + +impl PredefinedEntry for key::GroupContextIndex { + type Codec = Borsh; + type DataType<'a> = (); +} + +impl PredefinedEntry for key::ContextGroupRef { + type Codec = Borsh; + type DataType<'a> = [u8; 32]; +} + +impl PredefinedEntry for key::GroupUpgradeKey { + type Codec = Borsh; + type DataType<'a> = key::GroupUpgradeValue; +} + +impl PredefinedEntry for key::GroupSigningKey { + type Codec = Borsh; + type DataType<'a> = key::GroupSigningKeyValue; +} + +impl PredefinedEntry for key::GroupMemberCapability { + type Codec = Borsh; + type DataType<'a> = key::GroupMemberCapabilityValue; +} + +impl PredefinedEntry for key::GroupContextVisibility { + type Codec = Borsh; + type DataType<'a> = key::GroupContextVisibilityValue; +} + +impl PredefinedEntry for key::GroupContextAllowlist { + type Codec = Borsh; + type DataType<'a> = (); +} + +impl PredefinedEntry for key::GroupDefaultCaps { + type Codec = Borsh; + type DataType<'a> = key::GroupDefaultCapsValue; +} + +impl PredefinedEntry for key::GroupDefaultVis { + type Codec = Borsh; + type DataType<'a> = key::GroupDefaultVisValue; +} + +impl PredefinedEntry for key::GroupContextLastMigration { + type Codec = Borsh; + type DataType<'a> = key::GroupContextLastMigrationValue; +} + +impl PredefinedEntry for key::GroupContextAlias { + type Codec = Borsh; + type DataType<'a> = String; +} + +impl PredefinedEntry for key::GroupMemberAlias { + type Codec = Borsh; + type DataType<'a> = String; +} + +impl PredefinedEntry for key::GroupAlias { + type Codec = Borsh; + type DataType<'a> = String; +} diff --git a/docs/context-management/GROUP-FEATURE-OVERVIEW.md b/docs/context-management/GROUP-FEATURE-OVERVIEW.md new file mode 100644 index 0000000000..ac426943d7 --- /dev/null +++ b/docs/context-management/GROUP-FEATURE-OVERVIEW.md @@ -0,0 +1,602 @@ +# Context Group Management + +Context Groups let you organize multiple contexts under a single workspace, manage +shared membership, and propagate application upgrades across all contexts at once. + +**If you're looking for:** +- [What groups are and why they exist](#what-are-context-groups) +- [Creating and managing groups (CLI)](#cli-reference) +- [HTTP API reference](#http-api) +- [Permission system](#permissions) +- [How invitations work](#invitations) +- [Upgrade propagation](#upgrade-propagation) +- [Group aliases](#group-aliases) +- [Architecture internals](#architecture-internals) + +--- + +## What Are Context Groups? + +Calimero's base unit is a **context** -- an application instance with its own state, +members, and sync topic. This works fine individually, but at scale you run into +problems: + +- A chat app with 500 DMs + 30 channels = 531 independent contexts +- Upgrading the app version requires 531 separate operations +- There's no concept of "these contexts belong together" + +A **Context Group** (or just "group") solves this: + +``` +Group "TeamChat" (app: chat-v2.3) + |-- #general (chat-v2.3) + |-- #engineering (chat-v2.3) + |-- DM-alice-bob (chat-v2.3) + +-- #random (chat-v2.3) +``` + +All contexts in a group run the **same application**. Upgrading the group to v2.4 +propagates to every context automatically. + +### Key Concepts + +| Concept | Description | +|---------|-------------| +| **Group** | A workspace that owns users and contexts sharing one application | +| **Admin** | Identity that controls the group (membership, upgrades, settings) | +| **Member** | Identity authorized to create/join contexts within the group | +| **Capabilities** | Per-member permission bits (what a member can do) | +| **Visibility** | Per-context access mode (Open or Restricted with allowlists) | +| **Alias** | Optional human-friendly name for a group (local-only, not on-chain) | + +### Group Membership vs Context Membership + +These are separate. Being a group member lets you *create* and *join* contexts +(subject to permissions), but each context has its own member set. A DM context +has 2 participants, not the entire group. This preserves privacy. + +--- + +## CLI Reference + +All group operations use `meroctl --node group ...`. + +Most flags like `--requester` and `--admin-identity` are optional -- they default +to the node's dedicated group identity (`[identity.group]` in `config.toml`), +generated at `merod init` time. + +### Group Lifecycle + +```bash +# List all groups +meroctl --node group list + +# Create a group +meroctl --node group create --application-id + # [optional] --app-key (auto-generated if omitted) + # [optional] --admin-identity (defaults to node group identity) + +# Get group info +meroctl --node group get + +# Update group settings (upgrade policy) +meroctl --node group update --upgrade-policy + +# Delete group (must have no registered contexts) +meroctl --node group delete +``` + +### Members + +```bash +# List members (shows role + capabilities) +meroctl --node group members list + +# Add a member +meroctl --node group members add --identity + +# Remove a member (cascades to all group contexts) +meroctl --node group members remove --identities + +# Change role (Admin <-> Member) +meroctl --node group members set-role --identity --role + +# Set capabilities +meroctl --node group members set-capabilities \ + [--can-create-context] [--can-invite-members] [--can-join-open-contexts] + +# View capabilities +meroctl --node group members get-capabilities +``` + +### Invitations + +Invitations use a two-phase commit/reveal protocol for MEV protection on-chain. +The CLI handles this transparently. + +```bash +# Admin creates an invitation (outputs JSON) +meroctl --node group invite + # [optional] --expiration-block-height N (defaults to 999_999_999) + +# Joiner uses the invitation JSON to join +meroctl --node group join '' +``` + +Invitations are `SignedGroupOpenInvitation` JSON -- transparent and inspectable. +Anyone with the JSON can join (no pre-assigned invitee). + +### Contexts in a Group + +```bash +# Create a context inside a group (app version auto-set to group target) +meroctl --node context create --protocol near --application-id \ + --group-id + +# List contexts in a group +meroctl --node group contexts list + +# Detach a context from a group +meroctl --node group contexts detach + +# Join an existing group context (as a group member) +meroctl --node group join-group-context --context-id +``` + +### Context Visibility & Allowlists + +```bash +# Set visibility (open or restricted) +meroctl --node group contexts set-visibility --mode + +# View visibility +meroctl --node group contexts get-visibility + +# Manage allowlist for restricted contexts +meroctl --node group contexts allowlist list +meroctl --node group contexts allowlist add ... +meroctl --node group contexts allowlist remove ... +``` + +### Group Defaults + +```bash +# Set default capabilities for new members +meroctl --node group settings set-default-capabilities \ + [--can-create-context] [--can-invite-members] [--can-join-open-contexts] + +# Set default visibility for new contexts +meroctl --node group settings set-default-visibility --mode +``` + +### Upgrades + +```bash +# Trigger version upgrade (canary -> propagate) +meroctl --node group upgrade trigger --target-application-id + +# Check upgrade status +meroctl --node group upgrade status + +# Retry failed upgrades +meroctl --node group upgrade retry +``` + +### Sync & Misc + +```bash +# Sync group state from on-chain contract +meroctl --node group sync + +# Register a signing key for a group +meroctl --node group signing-key register + +# Delete a group-owned context (must be group admin) +meroctl --node context delete +``` + +--- + +## HTTP API + +Base path: `/admin-api` + +### Group CRUD + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/groups` | List all groups | +| POST | `/groups` | Create group (accepts optional `alias`) | +| GET | `/groups/:id` | Get group info (includes optional `alias`) | +| PATCH | `/groups/:id` | Update group settings | +| DELETE | `/groups/:id` | Delete group | + +### Members + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/groups/:id/members` | List members (role + capabilities) | +| POST | `/groups/:id/members` | Add members | +| POST | `/groups/:id/members/remove` | Remove members (cascades) | +| PUT | `/groups/:id/members/:identity/role` | Set member role | +| GET | `/groups/:id/members/:identity/capabilities` | Get capabilities | +| PUT | `/groups/:id/members/:identity/capabilities` | Set capabilities | + +### Contexts + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/groups/:id/contexts` | List group contexts | +| POST | `/groups/:id/contexts/:ctx/remove` | Detach context | +| GET | `/groups/:id/contexts/:ctx/visibility` | Get visibility mode | +| PUT | `/groups/:id/contexts/:ctx/visibility` | Set visibility mode | +| GET | `/groups/:id/contexts/:ctx/allowlist` | Get allowlist | +| POST | `/groups/:id/contexts/:ctx/allowlist` | Manage allowlist (add/remove) | +| GET | `/contexts/:ctx/group` | Get context's parent group | + +### Invitations + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/groups/:id/invite` | Create invitation (response includes `groupAlias`) | +| POST | `/groups/join` | Join via invitation (accepts `groupAlias` hint) | +| POST | `/groups/:id/join-context` | Join a context via group membership | + +### Settings & Upgrades + +| Method | Path | Purpose | +|--------|------|---------| +| PUT | `/groups/:id/settings/default-capabilities` | Default capabilities for new members | +| PUT | `/groups/:id/settings/default-visibility` | Default visibility for new contexts | +| POST | `/groups/:id/upgrade` | Trigger version upgrade | +| GET | `/groups/:id/upgrade/status` | Upgrade progress | +| POST | `/groups/:id/upgrade/retry` | Retry failed upgrades | +| POST | `/groups/:id/sync` | Sync from on-chain contract | +| POST | `/groups/:id/signing-key` | Register signing key | + +### Aliases + +| Method | Path | Purpose | +|--------|------|---------| +| PUT | `/groups/:id/alias` | Set/update group alias (local-only) | + +--- + +## Permissions + +### Member Capabilities + +Each group member has a capability bitfield (`u32`) controlling what they can do: + +| Capability | What it allows | +|------------|---------------| +| `CAN_CREATE_CONTEXT` | Register new contexts in the group | +| `CAN_INVITE_MEMBERS` | Create group invitations for others | +| `CAN_JOIN_OPEN_CONTEXTS` | Join contexts with Open visibility | + +**Admins bypass all capability checks.** + +When a member is added (via `add_group_members` or invitation), they receive the +group's `default_member_capabilities` (default: `CAN_JOIN_OPEN_CONTEXTS`). Admins +can change this default or set capabilities per-member. + +### Context Visibility + +Each context in a group has a visibility mode: + +| Mode | Who can join | +|------|-------------| +| **Open** (default) | Any group member with `CAN_JOIN_OPEN_CONTEXTS` | +| **Restricted** | Only members on the context's allowlist | + +When a context is registered as Restricted, the creator is automatically added to the +allowlist (prevents accidental lockout). Allowlists can be pre-populated on Open +contexts before switching to Restricted. + +### Authorization Matrix + +| Operation | Admin | Member (with cap) | Member (no cap) | +|-----------|:-----:|:-----------------:|:---------------:| +| Create context | Yes | Yes (`CAN_CREATE_CONTEXT`) | No | +| Join Open context | Yes | Yes (`CAN_JOIN_OPEN_CONTEXTS`) | No | +| Join Restricted context | Yes (logged) | Yes (if on allowlist) | No | +| Create invitation | Yes | Yes (`CAN_INVITE_MEMBERS`) | No | +| Set capabilities | Yes | No | No | +| Set visibility | Yes or creator | No | No | +| Manage allowlist | Yes or creator | No | No | +| Set defaults | Yes | No | No | + +When an admin force-joins a Restricted context they're not on the allowlist for, +a structured NEP-297 event (`AdminContextJoinEvent`) is emitted for auditability. + +--- + +## Invitations + +Group invitations use a **commit-reveal protocol** to prevent front-running on-chain. + +### How It Works + +**Step 1 -- Commit:** The joiner submits `SHA256(payload)` as a commitment to the +contract, stored with an expiration block height. + +**Step 2 -- Reveal:** The joiner submits the full `SignedGroupRevealPayload`: +- `GroupInvitationFromAdmin`: group_id, inviter identity, expiration, secret salt +- `inviter_signature`: Ed25519 signature from the inviter +- `invitee_signature`: Ed25519 signature from the joiner + +The contract verifies: +1. Commitment hash matches the revealed payload +2. Block height is within expiration +3. Both signatures are valid +4. Inviter is admin or has `CAN_INVITE_MEMBERS` +5. Joiner is not already a member +6. Invitation hasn't been used before (replay protection) + +On success, the joiner is added to the group with `default_member_capabilities`. + +### Flow Diagram + +``` +Admin (Node A) Joiner (Node B) On-Chain Contract + | | | + | SignedGroupOpenInvitation | | + | (JSON) ----------------->| | + | | | + | | commit_group_invitation + | |---------------------->| store commitment + | |<----------------------| ok + | | | + | | reveal_group_invitation + | |---------------------->| verify signatures + | |<----------------------| ok, member added + | | | + | group sync | | + | -> sees new member | sees both members | +``` + +--- + +## Upgrade Propagation + +When an admin triggers a group upgrade: + +1. **Canary**: The first context is upgraded as validation +2. **Contract update**: Group's `target_application` updated on-chain + locally +3. **Background propagation**: Remaining contexts upgraded sequentially +4. **Crash recovery**: Progress persisted in `GroupUpgradeValue`; resumes on restart + +### Upgrade Policies + +| Policy | Behavior | +|--------|----------| +| `Automatic` | All contexts upgraded immediately | +| `LazyOnAccess` | Contexts upgraded on next interaction | +| `Coordinated { deadline }` | Opt-in window with forced deadline | + +### Peer Propagation + +The `migration_method` field is stored on-chain. During group sync, peer nodes: +1. Discover the new target application version +2. Fetch the application binary via P2P blob sharing +3. Install the binary locally +4. Apply migration on next context access (`maybe_lazy_upgrade`) + +--- + +## Group Aliases + +Aliases provide human-friendly names for groups (e.g., "TeamChat" instead of a 32-byte hex ID). + +**Aliases are local-only** -- they are never stored on-chain. This keeps on-chain +storage costs down and allows different nodes to use different names for the same group. + +### Setting an Alias + +```bash +# Via CLI (during group creation -- not yet exposed, use API) +# Via API: +curl -X PUT http://localhost:2428/admin-api/groups//alias \ + -H "Content-Type: application/json" \ + -d '{"alias": "TeamChat"}' +``` + +Aliases propagate to peer nodes via gossip (`GroupMutationKind::GroupAliasSet`). + +### Where Aliases Appear + +- `GET /admin-api/groups` -- each group summary includes its alias +- `GET /admin-api/groups/:id` -- response includes alias +- `POST /admin-api/groups` -- accepts optional `alias` on creation +- `POST /admin-api/groups/:id/invite` -- invitation response includes `groupAlias` +- `POST /admin-api/groups/join` -- join request accepts `groupAlias` hint + +--- + +## Architecture Internals + +This section covers implementation details for contributors and advanced users. + +### Identity Model + +Calimero uses two distinct identity types: + +| Identity | Scope | Created when | Used for | +|----------|-------|-------------|----------| +| `SignerId` (ed25519) | Group-level | `merod init` | Admin actions, group membership | +| `ContextIdentity` (ed25519) | Per-context | Each `join_context_via_group` | Context state mutations, execution | + +A group member has **one SignerId** but potentially **many ContextIdentity keys** -- +one per context they've joined. These per-context keys are random keypairs unrelated +to the group identity. This separation means compromising one context key doesn't +affect the group or other contexts. + +### The `member_contexts` Mapping + +``` +member_contexts: Map<(SignerId, ContextId), ContextIdentity> +``` + +This mapping is the backbone of cascade operations: +- **Populated** when a member joins a context via the group +- **Consumed** when removing a member from the group (cascade-removes from all contexts) +- **Cleaned up** when unregistering a context, deleting a group, or erasing the contract + +Only tracks group-authorized joins. Members who joined via direct context invitation +are unaffected by group removal. + +### Cascade Removal + +When an admin removes a member from a group: + +``` +Admin calls remove_group_members(member_pk) + | + |-- Phase 1: Group removal + | |-- Remove from group.members + | +-- Collect all (member, ctx) -> identity mappings + | + +-- Phase 2: Context cascade (for each context) + |-- Remove ContextIdentity from context.members + |-- Remove nonce + |-- Revoke member privileges + +-- Revoke app privileges +``` + +The removed member's past contributions remain in the DAG -- removal only prevents +future access. + +### On-Chain Data Model + +The `OnChainGroupMeta` struct (NEAR contract): + +``` +OnChainGroupMeta + |-- app_key: AppKey + |-- target_application: Application + |-- admins: IterableSet + |-- admin_nonces: IterableMap (replay protection) + |-- members: IterableSet + |-- approved_registrations: IterableSet + |-- context_ids: IterableSet (O(1) count via .len()) + |-- invitation_commitments: IterableMap + |-- used_invitations: IterableSet (replay protection) + |-- member_contexts: IterableMap<(SignerId, ContextId), ContextIdentity> + |-- migration_method: Option + |-- member_capabilities: IterableMap + |-- context_visibility: IterableMap + |-- context_allowlists: IterableMap<(ContextId, SignerId), ()> + |-- default_member_capabilities: u32 + +-- default_context_visibility: VisibilityMode +``` + +### Local Storage Keys (Node) + +| Key Type | Prefix | Content | +|----------|--------|---------| +| `GroupMeta` | `0x20` | App key, target app, upgrade policy, admin, migration | +| `GroupMember` | `0x21` | Role (Admin / Member) | +| `GroupContextIndex` | `0x22` | Context belongs to group (presence index) | +| `ContextGroupRef` | `0x23` | Context -> group (reverse index) | +| `GroupUpgradeKey` | `0x24` | Upgrade state tracking | +| `GroupSigningKey` | `0x25` | Private signing key for group operations | +| `GroupMemberCapability` | `0x26` | Capability bitfield per member | +| `GroupContextVisibility` | `0x27` | Visibility mode + creator per context | +| `GroupContextAllowlist` | `0x28` | Allowlist entries (presence index) | +| `GroupDefaultCapabilities` | `0x29` | Default capabilities for new members | +| `GroupDefaultVisibility` | `0x2A` | Default visibility for new contexts | +| `GroupAlias` | `0x2E` | Human-friendly group name | + +### Guard Access Control + +The `Guard` wrapper on context data (member lists, application settings) controls +mutable access via a set of **privileged signers** and a **revision counter** that +auto-increments on mutation (used for sync detection). + +Group operations that need to modify context data (e.g., adding a member via +`join_context_via_group`) use `authorized_get_mut()` -- a method that provides +direct mutable access for contract-internal operations where authorization has +already been verified at a higher level (group membership check). + +### Storage Cleanup + +Every NEAR storage `insert` must have a corresponding `remove` path, or storage +leaks permanently (costing NEAR staking tokens). The cleanup matrix: + +| Collection | `delete_group` | `unregister_context` | `proxy_unregister` | `erase` | `remove_members` | +|------------|:-:|:-:|:-:|:-:|:-:| +| `admins` | clear | -- | -- | clear | -- | +| `admin_nonces` | clear | -- | -- | clear | -- | +| `members` | clear | -- | -- | clear | remove | +| `approved_registrations` | clear | -- | -- | clear | -- | +| `context_ids` | clear | remove | remove | clear | -- | +| `invitation_commitments` | clear | -- | -- | clear | -- | +| `used_invitations` | clear | -- | -- | clear | -- | +| `member_contexts` | clear | per-context | per-context | clear | cascade | +| `member_capabilities` | clear | -- | -- | clear | remove | +| `context_visibility` | clear | remove | remove | clear | -- | +| `context_allowlists` | clear | per-context | per-context | clear | -- | + +### Migration History + +| Migration | What it adds | +|-----------|-------------| +| `03_context_groups` | `groups` and `context_group_refs` maps, `group_id` on Context | +| `04_group_invitations` | `invitation_commitments`, `used_invitations`, `member_contexts` | +| `05_group_migration_method` | `migration_method: Option` | +| `06_group_permissions` | Capabilities, visibility, allowlists, defaults; existing members get `CAN_JOIN_OPEN_CONTEXTS` | + +### One Group = One Application + +A group is tied to a single `target_application`. All contexts run the same app. +This is intentional -- upgrade propagation requires all contexts to use the same +binary. + +For multi-app scenarios, use multiple groups: + +``` +Workspace "Acme Corp" + |-- Group "AcmeChat" (app: chat-v2.3) -> chat contexts + |-- Group "AcmeFiles" (app: filestorage-v1.0) -> file contexts + +-- Group "AcmeTasks" (app: taskboard-v3.1) -> task contexts +``` + +### Sync Model + +Group state synchronization is currently **pull-based** -- nodes run `group sync` +to fetch the latest state from the on-chain contract. Group aliases are the exception: +they propagate via P2P gossip (`GroupMutationKind::GroupAliasSet`). + +Full push-based propagation for all group state changes is planned but not yet implemented. + +--- + +## Code Map + +### Contract (`contracts/`) + +| File | Purpose | +|------|---------| +| `near/context-config/src/lib.rs` | Data structures, types, storage prefixes | +| `near/context-config/src/mutate.rs` | All group mutations | +| `near/context-config/src/query.rs` | Read-only queries | +| `near/context-config/src/group_invitation.rs` | Commit/reveal invitation protocol | +| `near/context-config/src/guard.rs` | Guard access control | +| `near/context-config/src/sys.rs` | System operations (erase, migrations) | +| `near/context-config/src/sys/migrations/` | Schema migrations (03-06 for groups) | +| `near/context-config/tests/groups.rs` | Integration tests | + +### Core (`core/`) + +| File | Purpose | +|------|---------| +| `crates/context/src/group_store.rs` | Local storage CRUD | +| `crates/store/src/key/group.rs` | Storage key types (prefixes 0x20-0x2E) | +| `crates/context/src/handlers/` | All group operation handlers | +| `crates/context/primitives/src/client/external/group.rs` | Contract client wrapper | +| `crates/context/primitives/src/group.rs` | Message/request types | +| `crates/server/src/admin/handlers/groups/` | HTTP endpoint handlers | +| `crates/server/src/admin/service.rs` | Route registration | +| `crates/server/primitives/src/admin.rs` | API request/response types | +| `crates/meroctl/src/cli/group/` | CLI commands | +| `crates/node/primitives/src/sync/snapshot.rs` | Gossip sync variants |