use appcore_types::{CapabilityName, ClusterId, CoreId, ProtocolVersion, TenantId, TraceContext};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt::{Debug, Formatter};
pub const PEER_RPC_PROTOCOL_VERSION: u16 = 1;
pub const PEER_HEALTH_PATH: &str = "/v1/peer/health";
pub const PEER_MANIFEST_PATH: &str = "/v1/peer/manifest";
pub const PEER_QUERY_PATH: &str = "/v1/peer/query";
pub const PEER_COMMAND_PATH: &str = "/v1/peer/command";
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcEnvelope {
pub request_id: String,
pub trace_id: String,
#[serde(default)]
pub protocol_version: ProtocolVersion,
pub source_core_id: CoreId,
pub target_core_id: CoreId,
pub tenant_id: TenantId,
pub cluster_id: ClusterId,
pub timestamp_ms: u64,
pub expires_at_ms: u64,
pub nonce: String,
pub capability: CapabilityName,
pub payload: Vec<u8>,
pub idempotency_key: Option<String>,
pub body_hash: String,
pub trace: Option<TraceContext>,
}
impl Debug for PeerRpcEnvelope {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PeerRpcEnvelope")
.field("request_id", &self.request_id)
.field("trace_id", &self.trace_id)
.field("protocol_version", &self.protocol_version)
.field("source_core_id", &self.source_core_id)
.field("target_core_id", &self.target_core_id)
.field("tenant_id", &self.tenant_id)
.field("cluster_id", &self.cluster_id)
.field("timestamp_ms", &self.timestamp_ms)
.field("expires_at_ms", &self.expires_at_ms)
.field("capability", &self.capability)
.field("payload_bytes", &self.payload.len())
.field("body_hash", &self.body_hash)
.field("has_idempotency_key", &self.idempotency_key.is_some())
.field("trace", &self.trace)
.finish()
}
}
impl PeerRpcEnvelope {
#[allow(clippy::too_many_arguments)]
pub fn new(
request_id: impl Into<String>,
trace_id: impl Into<String>,
source_core_id: CoreId,
target_core_id: CoreId,
tenant_id: TenantId,
cluster_id: ClusterId,
timestamp_ms: u64,
expires_at_ms: u64,
nonce: impl Into<String>,
capability: CapabilityName,
payload: Vec<u8>,
idempotency_key: Option<String>,
trace: Option<TraceContext>,
) -> Self {
let body_hash = payload_hash(&payload);
Self {
request_id: request_id.into(),
trace_id: trace_id.into(),
protocol_version: ProtocolVersion::default(),
source_core_id,
target_core_id,
tenant_id,
cluster_id,
timestamp_ms,
expires_at_ms,
nonce: nonce.into(),
capability,
payload,
idempotency_key,
body_hash,
trace,
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerRpcResponse {
pub ok: bool,
pub request_id: String,
pub payload: Vec<u8>,
pub error: Option<String>,
}
impl Debug for PeerRpcResponse {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PeerRpcResponse")
.field("ok", &self.ok)
.field("request_id", &self.request_id)
.field("payload_bytes", &self.payload.len())
.field("has_error", &self.error.is_some())
.finish()
}
}
impl PeerRpcResponse {
pub fn ok(request_id: impl Into<String>, payload: Vec<u8>) -> Self {
Self {
ok: true,
request_id: request_id.into(),
payload,
error: None,
}
}
pub fn rejected(request_id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
ok: false,
request_id: request_id.into(),
payload: Vec::new(),
error: Some(error.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PeerRpcError {
#[error("peer RPC payload is too large")]
PayloadTooLarge,
#[error("peer RPC request is unauthorized")]
Unauthorized,
#[error("peer RPC request is forbidden")]
Forbidden,
#[error("peer RPC endpoint is unavailable")]
EndpointUnavailable,
#[error("peer RPC tenant mismatch")]
TenantMismatch,
#[error("peer RPC cluster mismatch")]
ClusterMismatch,
#[error("peer RPC target mismatch")]
TargetMismatch,
#[error("peer RPC protocol mismatch")]
ProtocolMismatch,
#[error("peer RPC envelope expired")]
Expired,
#[error("peer RPC nonce replay")]
NonceReplay,
#[error("peer RPC nonce cache is full")]
NonceCacheFull,
#[error("peer RPC body hash is invalid")]
InvalidBodyHash,
#[error("invalid peer RPC response: {0}")]
InvalidResponse(String),
#[error("peer RPC transport failed: {0}")]
Transport(String),
#[error("invalid peer RPC envelope: {0}")]
InvalidEnvelope(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerRpcCallKind {
Query,
Command,
}
#[derive(Clone, PartialEq, Eq)]
pub struct PeerRpcOutboundRequest {
pub request_id: String,
pub target_core_id: CoreId,
pub capability: CapabilityName,
pub payload: Vec<u8>,
pub idempotency_key: Option<String>,
pub trace: Option<TraceContext>,
}
impl Debug for PeerRpcOutboundRequest {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PeerRpcOutboundRequest")
.field("request_id", &self.request_id)
.field("target_core_id", &self.target_core_id)
.field("capability", &self.capability)
.field("payload_bytes", &self.payload.len())
.field("has_idempotency_key", &self.idempotency_key.is_some())
.field("trace", &self.trace)
.finish()
}
}
impl PeerRpcOutboundRequest {
pub fn new(
request_id: impl Into<String>,
target_core_id: CoreId,
capability: CapabilityName,
payload: Vec<u8>,
idempotency_key: Option<String>,
trace: Option<TraceContext>,
) -> Self {
Self {
request_id: request_id.into(),
target_core_id,
capability,
payload,
idempotency_key,
trace,
}
}
}
pub trait PeerRpcClientExecutor: Send + Sync {
fn call_peer(
&self,
endpoint_url: &str,
kind: PeerRpcCallKind,
request: PeerRpcOutboundRequest,
) -> Result<PeerRpcResponse, PeerRpcError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerHealthResponse {
pub ok: bool,
pub core_id: CoreId,
pub tenant_id: TenantId,
pub cluster_id: ClusterId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerManifestResponse {
pub advertisement: PeerAdvertisementV1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerAdvertisementV1 {
pub schema_version: u16,
pub identity: PeerIdentityV1,
pub app_name: String,
pub app_version: String,
pub runtime_min_version: String,
pub runtime_max_version: Option<String>,
pub capabilities: Vec<PeerCapabilityV1>,
pub endpoints: Vec<PeerEndpointV1>,
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerIdentityV1 {
pub tenant_id: String,
pub cluster_id: String,
pub core_id: String,
pub instance_id: String,
pub kind: String,
pub protocol_version: u16,
pub app_id: String,
pub app_family: String,
pub sync_group: String,
pub runtime_contract: u16,
pub node_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCapabilityV1 {
pub name: String,
pub version: String,
pub mode: String,
pub visibility: String,
pub requires_leader: bool,
pub read_only: bool,
pub idempotency_required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerEndpointV1 {
pub name: String,
pub url: String,
pub protocol: String,
pub metadata: BTreeMap<String, String>,
}
fn payload_hash(payload: &[u8]) -> String {
let digest = Sha256::digest(payload);
let mut output = String::with_capacity(digest.len() * 2);
const HEX: &[u8; 16] = b"0123456789abcdef";
for byte in digest {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_has_stable_v1_json_shape() {
let envelope = PeerRpcEnvelope::new(
"req-1",
"trace-1",
CoreId::new("core-a").unwrap(),
CoreId::new("core-b").unwrap(),
TenantId::new("tenant-a").unwrap(),
ClusterId::new("cluster-a").unwrap(),
10,
20,
"nonce-1",
CapabilityName::new("runtime.query").unwrap(),
b"hello".to_vec(),
None,
None,
);
let encoded = serde_json::to_value(envelope).unwrap();
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../fixtures/peer-rpc-envelope-v1.json")).unwrap();
assert_eq!(encoded, fixture);
}
#[test]
fn peer_debug_omits_opaque_payloads_and_error_details() {
let marker = b"secret-marker-must-not-appear";
let envelope = PeerRpcEnvelope::new(
"req-1",
"trace-1",
CoreId::new("core-a").unwrap(),
CoreId::new("core-b").unwrap(),
TenantId::new("tenant-a").unwrap(),
ClusterId::new("cluster-a").unwrap(),
10,
20,
"nonce-secret-marker-must-not-appear",
CapabilityName::new("runtime.query").unwrap(),
marker.to_vec(),
Some("secret-marker-must-not-appear".to_string()),
None,
);
let response = PeerRpcResponse::rejected("req-1", "secret-marker-must-not-appear");
let outbound = PeerRpcOutboundRequest::new(
"req-1",
CoreId::new("core-b").unwrap(),
CapabilityName::new("runtime.query").unwrap(),
marker.to_vec(),
Some("secret-marker-must-not-appear".to_string()),
None,
);
assert!(!format!("{envelope:?}").contains("secret-marker-must-not-appear"));
assert!(!format!("{response:?}").contains("secret-marker-must-not-appear"));
assert!(!format!("{outbound:?}").contains("secret-marker-must-not-appear"));
}
}