1use appcore_types::{CapabilityName, ClusterId, CoreId, ProtocolVersion, TenantId, TraceContext};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::BTreeMap;
17use std::fmt::{Debug, Formatter};
18
19pub const PEER_RPC_PROTOCOL_VERSION: u16 = 1;
21pub const PEER_HEALTH_PATH: &str = "/v1/peer/health";
23pub const PEER_MANIFEST_PATH: &str = "/v1/peer/manifest";
25pub const PEER_QUERY_PATH: &str = "/v1/peer/query";
27pub const PEER_COMMAND_PATH: &str = "/v1/peer/command";
29
30#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PeerRpcEnvelope {
33 pub request_id: String,
35 pub trace_id: String,
37 #[serde(default)]
39 pub protocol_version: ProtocolVersion,
40 pub source_core_id: CoreId,
42 pub target_core_id: CoreId,
44 pub tenant_id: TenantId,
46 pub cluster_id: ClusterId,
48 pub timestamp_ms: u64,
50 pub expires_at_ms: u64,
52 pub nonce: String,
54 pub capability: CapabilityName,
56 pub payload: Vec<u8>,
58 pub idempotency_key: Option<String>,
60 pub body_hash: String,
62 pub trace: Option<TraceContext>,
64}
65
66impl Debug for PeerRpcEnvelope {
67 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
68 formatter
69 .debug_struct("PeerRpcEnvelope")
70 .field("request_id", &self.request_id)
71 .field("trace_id", &self.trace_id)
72 .field("protocol_version", &self.protocol_version)
73 .field("source_core_id", &self.source_core_id)
74 .field("target_core_id", &self.target_core_id)
75 .field("tenant_id", &self.tenant_id)
76 .field("cluster_id", &self.cluster_id)
77 .field("timestamp_ms", &self.timestamp_ms)
78 .field("expires_at_ms", &self.expires_at_ms)
79 .field("capability", &self.capability)
80 .field("payload_bytes", &self.payload.len())
81 .field("body_hash", &self.body_hash)
82 .field("has_idempotency_key", &self.idempotency_key.is_some())
83 .field("trace", &self.trace)
84 .finish()
85 }
86}
87
88impl PeerRpcEnvelope {
89 #[allow(clippy::too_many_arguments)]
91 pub fn new(
92 request_id: impl Into<String>,
93 trace_id: impl Into<String>,
94 source_core_id: CoreId,
95 target_core_id: CoreId,
96 tenant_id: TenantId,
97 cluster_id: ClusterId,
98 timestamp_ms: u64,
99 expires_at_ms: u64,
100 nonce: impl Into<String>,
101 capability: CapabilityName,
102 payload: Vec<u8>,
103 idempotency_key: Option<String>,
104 trace: Option<TraceContext>,
105 ) -> Self {
106 let body_hash = payload_hash(&payload);
107 Self {
108 request_id: request_id.into(),
109 trace_id: trace_id.into(),
110 protocol_version: ProtocolVersion::default(),
111 source_core_id,
112 target_core_id,
113 tenant_id,
114 cluster_id,
115 timestamp_ms,
116 expires_at_ms,
117 nonce: nonce.into(),
118 capability,
119 payload,
120 idempotency_key,
121 body_hash,
122 trace,
123 }
124 }
125}
126
127#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct PeerRpcResponse {
130 pub ok: bool,
132 pub request_id: String,
134 pub payload: Vec<u8>,
136 pub error: Option<String>,
138}
139
140impl Debug for PeerRpcResponse {
141 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
142 formatter
143 .debug_struct("PeerRpcResponse")
144 .field("ok", &self.ok)
145 .field("request_id", &self.request_id)
146 .field("payload_bytes", &self.payload.len())
147 .field("has_error", &self.error.is_some())
148 .finish()
149 }
150}
151
152impl PeerRpcResponse {
153 pub fn ok(request_id: impl Into<String>, payload: Vec<u8>) -> Self {
155 Self {
156 ok: true,
157 request_id: request_id.into(),
158 payload,
159 error: None,
160 }
161 }
162
163 pub fn rejected(request_id: impl Into<String>, error: impl Into<String>) -> Self {
165 Self {
166 ok: false,
167 request_id: request_id.into(),
168 payload: Vec::new(),
169 error: Some(error.into()),
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
176pub enum PeerRpcError {
177 #[error("peer RPC payload is too large")]
179 PayloadTooLarge,
180 #[error("peer RPC request is unauthorized")]
182 Unauthorized,
183 #[error("peer RPC request is forbidden")]
185 Forbidden,
186 #[error("peer RPC endpoint is unavailable")]
188 EndpointUnavailable,
189 #[error("peer RPC tenant mismatch")]
191 TenantMismatch,
192 #[error("peer RPC cluster mismatch")]
194 ClusterMismatch,
195 #[error("peer RPC target mismatch")]
197 TargetMismatch,
198 #[error("peer RPC protocol mismatch")]
200 ProtocolMismatch,
201 #[error("peer RPC envelope expired")]
203 Expired,
204 #[error("peer RPC nonce replay")]
206 NonceReplay,
207 #[error("peer RPC nonce cache is full")]
209 NonceCacheFull,
210 #[error("peer RPC body hash is invalid")]
212 InvalidBodyHash,
213 #[error("invalid peer RPC response: {0}")]
215 InvalidResponse(String),
216 #[error("peer RPC transport failed: {0}")]
218 Transport(String),
219 #[error("invalid peer RPC envelope: {0}")]
221 InvalidEnvelope(String),
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum PeerRpcCallKind {
228 Query,
230 Command,
232}
233
234#[derive(Clone, PartialEq, Eq)]
236pub struct PeerRpcOutboundRequest {
237 pub request_id: String,
239 pub target_core_id: CoreId,
241 pub capability: CapabilityName,
243 pub payload: Vec<u8>,
245 pub idempotency_key: Option<String>,
247 pub trace: Option<TraceContext>,
249}
250
251impl Debug for PeerRpcOutboundRequest {
252 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
253 formatter
254 .debug_struct("PeerRpcOutboundRequest")
255 .field("request_id", &self.request_id)
256 .field("target_core_id", &self.target_core_id)
257 .field("capability", &self.capability)
258 .field("payload_bytes", &self.payload.len())
259 .field("has_idempotency_key", &self.idempotency_key.is_some())
260 .field("trace", &self.trace)
261 .finish()
262 }
263}
264
265impl PeerRpcOutboundRequest {
266 pub fn new(
268 request_id: impl Into<String>,
269 target_core_id: CoreId,
270 capability: CapabilityName,
271 payload: Vec<u8>,
272 idempotency_key: Option<String>,
273 trace: Option<TraceContext>,
274 ) -> Self {
275 Self {
276 request_id: request_id.into(),
277 target_core_id,
278 capability,
279 payload,
280 idempotency_key,
281 trace,
282 }
283 }
284}
285
286pub trait PeerRpcClientExecutor: Send + Sync {
288 fn call_peer(
290 &self,
291 endpoint_url: &str,
292 kind: PeerRpcCallKind,
293 request: PeerRpcOutboundRequest,
294 ) -> Result<PeerRpcResponse, PeerRpcError>;
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct PeerHealthResponse {
300 pub ok: bool,
302 pub core_id: CoreId,
304 pub tenant_id: TenantId,
306 pub cluster_id: ClusterId,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct PeerManifestResponse {
313 pub advertisement: PeerAdvertisementV1,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct PeerAdvertisementV1 {
320 pub schema_version: u16,
322 pub identity: PeerIdentityV1,
324 pub app_name: String,
326 pub app_version: String,
328 pub runtime_min_version: String,
330 pub runtime_max_version: Option<String>,
332 pub capabilities: Vec<PeerCapabilityV1>,
334 pub endpoints: Vec<PeerEndpointV1>,
336 pub metadata: BTreeMap<String, String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct PeerIdentityV1 {
343 pub tenant_id: String,
345 pub cluster_id: String,
347 pub core_id: String,
349 pub instance_id: String,
351 pub kind: String,
353 pub protocol_version: u16,
355 pub app_id: String,
357 pub app_family: String,
359 pub sync_group: String,
361 pub runtime_contract: u16,
363 pub node_id: String,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct PeerCapabilityV1 {
370 pub name: String,
372 pub version: String,
374 pub mode: String,
376 pub visibility: String,
378 pub requires_leader: bool,
380 pub read_only: bool,
382 pub idempotency_required: bool,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct PeerEndpointV1 {
389 pub name: String,
391 pub url: String,
393 pub protocol: String,
395 pub metadata: BTreeMap<String, String>,
397}
398
399fn payload_hash(payload: &[u8]) -> String {
400 let digest = Sha256::digest(payload);
401 let mut output = String::with_capacity(digest.len() * 2);
402 const HEX: &[u8; 16] = b"0123456789abcdef";
403 for byte in digest {
404 output.push(HEX[(byte >> 4) as usize] as char);
405 output.push(HEX[(byte & 0x0f) as usize] as char);
406 }
407 output
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn envelope_has_stable_v1_json_shape() {
416 let envelope = PeerRpcEnvelope::new(
417 "req-1",
418 "trace-1",
419 CoreId::new("core-a").unwrap(),
420 CoreId::new("core-b").unwrap(),
421 TenantId::new("tenant-a").unwrap(),
422 ClusterId::new("cluster-a").unwrap(),
423 10,
424 20,
425 "nonce-1",
426 CapabilityName::new("runtime.query").unwrap(),
427 b"hello".to_vec(),
428 None,
429 None,
430 );
431 let encoded = serde_json::to_value(envelope).unwrap();
432 let fixture: serde_json::Value =
433 serde_json::from_str(include_str!("../fixtures/peer-rpc-envelope-v1.json")).unwrap();
434 assert_eq!(encoded, fixture);
435 }
436
437 #[test]
438 fn peer_debug_omits_opaque_payloads_and_error_details() {
439 let marker = b"secret-marker-must-not-appear";
440 let envelope = PeerRpcEnvelope::new(
441 "req-1",
442 "trace-1",
443 CoreId::new("core-a").unwrap(),
444 CoreId::new("core-b").unwrap(),
445 TenantId::new("tenant-a").unwrap(),
446 ClusterId::new("cluster-a").unwrap(),
447 10,
448 20,
449 "nonce-secret-marker-must-not-appear",
450 CapabilityName::new("runtime.query").unwrap(),
451 marker.to_vec(),
452 Some("secret-marker-must-not-appear".to_string()),
453 None,
454 );
455 let response = PeerRpcResponse::rejected("req-1", "secret-marker-must-not-appear");
456 let outbound = PeerRpcOutboundRequest::new(
457 "req-1",
458 CoreId::new("core-b").unwrap(),
459 CapabilityName::new("runtime.query").unwrap(),
460 marker.to_vec(),
461 Some("secret-marker-must-not-appear".to_string()),
462 None,
463 );
464
465 assert!(!format!("{envelope:?}").contains("secret-marker-must-not-appear"));
466 assert!(!format!("{response:?}").contains("secret-marker-must-not-appear"));
467 assert!(!format!("{outbound:?}").contains("secret-marker-must-not-appear"));
468 }
469}