1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::{AuthRequest, AuthenticatedSession, ServiceCoreInput, ServiceCoreOutput};
5use std::collections::HashMap;
6
7pub const EXTERNAL_CORE_PROTOCOL_VERSION: u32 = 17;
8pub const ARTIFACT_CONTENT_PATH_PREFIX: &str = "/artifact/v1/content/";
9pub const ARTIFACT_UPLOAD_PATH_PREFIX: &str = "/artifact/v1/upload/";
10
11pub fn external_core_pipe_name(socket_path: &std::path::Path) -> String {
14 use sha2::{Digest, Sha256};
15 let normalized = socket_path
16 .to_string_lossy()
17 .replace('/', "\\")
18 .to_lowercase();
19 format!(
20 r"\\.\pipe\meowcore-{:x}",
21 Sha256::digest(normalized.as_bytes())
22 )
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExternalCoreRequest {
27 pub id: String,
28 pub method: ExternalCoreMethod,
29 pub payload: Value,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExternalCoreResponse {
34 pub id: String,
35 pub ok: bool,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub result: Option<Value>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub error: Option<ExternalCoreError>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ExternalCoreError {
44 pub code: String,
45 pub message: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub details: Option<Value>,
48}
49
50impl From<crate::ErrorResponse> for ExternalCoreError {
51 fn from(error: crate::ErrorResponse) -> Self {
52 Self {
53 code: error.error,
54 message: error.message,
55 details: error.details,
56 }
57 }
58}
59
60impl From<ExternalCoreError> for crate::ErrorResponse {
61 fn from(error: ExternalCoreError) -> Self {
62 Self {
63 error: error.code,
64 message: error.message,
65 details: error.details,
66 }
67 }
68}
69
70impl std::fmt::Display for ExternalCoreError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 write!(f, "[{}] {}", self.code, self.message)
73 }
74}
75
76impl std::error::Error for ExternalCoreError {}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct HttpPayloadResponse {
83 pub status_code: u16,
84 pub body: Value,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub enum ExternalCoreMethod {
89 HandleCoreInput,
90 RegisterLocalAppClient,
91 RegisterNodeConnection,
92 CleanupWsState,
93 UpdateCallbackBase,
94 PutArtifact,
95 OpenArtifact,
96 AuthorizeArtifactRead,
97 AuthorizeArtifactUpload,
98 CommitArtifactUpload,
99 DeviceIdentity,
100 CommissionFingerprint,
101 CommissionPublicKeyBase64,
102 ListMdnsRecords,
103 PollEvents,
104 CompleteEvents,
105 CommissionChallengePayload,
106 PairingStartPayload,
107 SetupStartPayload,
108 GeneralWebhookPayload,
109 InvokeWasmPayload,
110 Health,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HandleCoreInputRequest {
115 pub input: ServiceCoreInput,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct HandleCoreInputResponse {
120 pub output: ServiceCoreOutput,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct RegisterLocalAppClientRequest {
125 pub ws_id: String,
126 pub auth_request: AuthRequest,
127 pub session: AuthenticatedSession,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RegisterNodeConnectionRequest {
132 pub ws_id: String,
133 pub session_id: String,
134 pub node_type: String,
135 pub node_id: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub host_name: Option<String>,
138 pub tenant_id: String,
139 pub scope_id: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct CleanupWsStateRequest {
144 pub ws_id: String,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct UpdateCallbackBaseRequest {
149 pub network: crate::host::network::HostNetworkSnapshot,
151 pub port: u16,
153}
154
155impl UpdateCallbackBaseRequest {
156 pub fn callback_base(&self, now_ms: i64) -> Option<String> {
157 if self.port == 0 {
158 return None;
159 }
160 self.network
161 .available_ipv4(now_ms)
162 .map(|ip| format!("http://{ip}:{}", self.port))
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct ExternalCoreDeviceIdentity {
169 pub device_id: String,
170 pub device_name: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct ListMdnsRecordsRequest {
176 pub port: u16,
177 pub addresses: Vec<String>,
178 pub host_type: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct ExternalCoreMdnsRecord {
184 pub service_type: String,
185 pub instance_name: String,
186 pub port: u16,
187 pub properties: HashMap<String, String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct PollExternalCoreEventsRequest {
193 pub consumer_id: String,
194 pub max_events: u16,
195 pub timeout_ms: u64,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct PollExternalCoreEventsResponse {
201 pub events: Vec<ExternalCoreEvent>,
202 pub timed_out: bool,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct CompleteExternalCoreEventsRequest {
208 pub consumer_id: String,
209 pub completions: Vec<ExternalCoreEventCompletion>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct CompleteExternalCoreEventsResponse {
215 pub completed_event_ids: Vec<String>,
216 pub missing_event_ids: Vec<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ExternalCoreEvent {
222 pub event_id: String,
223 pub kind: ExternalCoreEventKind,
224 pub payload: Value,
225 pub expects_response: bool,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "camelCase")]
230pub enum ExternalCoreEventKind {
231 MdnsRecordsChanged,
232 DeliveryRequested,
233 ConnectionControlRequested,
234 HostRuntimeRequest,
235 ServiceAppFacadeRequest,
236 ScopeOwnedDataPurgeRequested,
237 ArtifactDeliveryProjectionRequested,
238 ArtifactUploadProjectionRequested,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase", deny_unknown_fields)]
245pub struct MessagingDeliveryRequest {
246 pub tenant_id: String,
247 pub scope_id: String,
248 pub surface_id: String,
249 pub target: String,
250 pub payload: String,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct MessagingDeliveryResponse {
256 pub outcome: crate::DeliveryOutcome,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261pub struct ExternalArtifactRequestContext {
262 pub tenant_id: String,
263 pub scope_id: String,
264 pub actor_user_id: String,
265 pub client_id: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase", deny_unknown_fields)]
270pub struct ExternalPutArtifactRequest {
271 pub context: ExternalArtifactRequestContext,
272 pub artifact: artifact_api::PutArtifactRequest,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct ExternalOpenArtifactRequest {
278 pub context: ExternalArtifactRequestContext,
279 pub uri: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase", deny_unknown_fields)]
284pub struct ExternalAuthorizeArtifactReadRequest {
285 pub grant: String,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290pub struct ExternalAuthorizeArtifactUploadRequest {
291 pub grant: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase", deny_unknown_fields)]
296pub struct ExternalCommitArtifactUploadRequest {
297 pub grant: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase", deny_unknown_fields)]
302pub struct ExternalArtifactReadDescriptor {
303 pub path: String,
304 pub kind: artifact_api::ArtifactKind,
305 pub mime_type: String,
306 pub size_bytes: u64,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub width: Option<u32>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub height: Option<u32>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub duration_millis: Option<u64>,
313 pub sha256: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct ExternalArtifactUploadDescriptor {
319 pub upload_id: String,
320 pub path: String,
321 pub kind: artifact_api::ArtifactKind,
322 pub mime_type: String,
323 pub size_bytes: u64,
324 pub sha256: String,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub duration_millis: Option<u64>,
327 pub expires_at_unix_ms: u64,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase", deny_unknown_fields)]
332pub struct ArtifactDeliveryProjectionRequest {
333 pub grant: String,
334 pub expires_at_unix_ms: u64,
335 pub client_id: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase", deny_unknown_fields)]
340pub struct ArtifactDeliveryProjectionResponse {
341 pub url: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase", deny_unknown_fields)]
346pub struct ArtifactUploadProjectionRequest {
347 pub grant: String,
348 pub expires_at_unix_ms: u64,
349 pub client_id: String,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ArtifactUploadProjectionResponse {
355 pub url: String,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ExternalServiceAppFacadeRequest {
363 pub target: String,
364 pub tenant_id: String,
365 pub scope_id: String,
366 pub user_id: String,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub client_id: Option<String>,
369 pub payload: Value,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct ExternalHostRuntimeRequest {
380 pub method: ExternalHostRuntimeMethod,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub candidate_id: Option<String>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub action: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub payload: Option<Value>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "camelCase")]
391pub enum ExternalHostRuntimeMethod {
392 Discovery,
393 CandidateRequest,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
397#[serde(rename_all = "camelCase")]
398pub struct ScopeOwnedDataPurgeRequest {
399 pub tenant_id: String,
400 pub scope_id: String,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct ExternalCoreEventCompletion {
406 pub event_id: String,
407 pub ok: bool,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub response: Option<Value>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub error: Option<ExternalCoreError>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct HttpPayloadRequest {
416 pub payload: Value,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct EndpointPayloadRequest {
421 pub endpoint_id: String,
422 pub payload: Value,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct ExternalCoreRuntimeMetadata {
428 pub backend: String,
429 pub instance_id: String,
430 pub pid: u32,
431 pub state: String,
432 pub started_at: String,
433 pub protocol_version: u32,
434 pub binary_version: String,
435 pub socket_path: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct ExternalCoreHealth {
441 pub ok: bool,
442 pub ready: bool,
443 pub backend: String,
444 pub binary_version: String,
445 pub agent_version: String,
446 pub protocol_version: u32,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub instance_id: Option<String>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub pid: Option<u32>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub state: Option<String>,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub started_at: Option<String>,
455 #[serde(default)]
456 pub capabilities: Vec<String>,
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn callback_address_requires_a_fresh_host_observation_and_bound_port() {
465 let mut request = UpdateCallbackBaseRequest {
466 network: crate::host::network::HostNetworkSnapshot {
467 lan_ipv4: Some("192.168.1.5".parse().unwrap()),
468 observed_at_ms: 100_000,
469 },
470 port: 3210,
471 };
472 assert_eq!(
473 request.callback_base(100_000).as_deref(),
474 Some("http://192.168.1.5:3210")
475 );
476 let roundtrip: UpdateCallbackBaseRequest =
477 serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap();
478 assert_eq!(request, roundtrip);
479 assert_eq!(request.callback_base(145_000), None);
480 request.port = 0;
481 assert_eq!(request.callback_base(100_000), None);
482 assert_eq!(
483 UpdateCallbackBaseRequest::default().callback_base(100_000),
484 None
485 );
486 }
487
488 #[test]
489 fn messaging_delivery_is_a_closed_request_response_contract() {
490 let request = serde_json::json!({"tenantId":"t", "scopeId":"s", "surfaceId":"m",
491 "target":"/chat/event", "payload":"{}"});
492 let decoded: MessagingDeliveryRequest = serde_json::from_value(request.clone()).unwrap();
493 assert_eq!(serde_json::to_value(decoded).unwrap(), request);
494 let mut invalid = request;
495 invalid["unknown"] = serde_json::json!(true);
496 assert!(serde_json::from_value::<MessagingDeliveryRequest>(invalid).is_err());
497 assert!(
498 serde_json::from_value::<MessagingDeliveryResponse>(serde_json::json!({})).is_err()
499 );
500 assert_eq!(
501 serde_json::to_value(ExternalCoreEventKind::DeliveryRequested).unwrap(),
502 "deliveryRequested"
503 );
504 }
505
506 #[test]
507 fn external_events_use_the_current_wire_shape() {
508 let event = ExternalCoreEvent {
509 event_id: "event-1".to_string(),
510 kind: ExternalCoreEventKind::ScopeOwnedDataPurgeRequested,
511 payload: serde_json::to_value(ScopeOwnedDataPurgeRequest {
512 tenant_id: "tenant-1".to_string(),
513 scope_id: "scope-1".to_string(),
514 })
515 .unwrap(),
516 expects_response: true,
517 };
518
519 let value = serde_json::to_value(event).unwrap();
520 assert_eq!(EXTERNAL_CORE_PROTOCOL_VERSION, 17);
521 assert_eq!(value["kind"], "scopeOwnedDataPurgeRequested");
522 assert_eq!(value["payload"]["tenantId"], "tenant-1");
523 assert_eq!(value["payload"]["scopeId"], "scope-1");
524 assert_eq!(value["expectsResponse"], true);
525
526 let facade = ExternalCoreEvent {
527 event_id: "event-2".to_string(),
528 kind: ExternalCoreEventKind::ServiceAppFacadeRequest,
529 payload: serde_json::to_value(ExternalServiceAppFacadeRequest {
530 target: "/app/messaging/provider/list".to_string(),
531 tenant_id: "tenant-1".to_string(),
532 scope_id: "scope-1".to_string(),
533 user_id: "user-1".to_string(),
534 client_id: None,
535 payload: serde_json::json!({"placement": "local"}),
536 })
537 .unwrap(),
538 expects_response: true,
539 };
540 let value = serde_json::to_value(facade).unwrap();
541 assert_eq!(value["kind"], "serviceAppFacadeRequest");
542 assert_eq!(value["payload"]["target"], "/app/messaging/provider/list");
543 assert_eq!(value["payload"]["userId"], "user-1");
544 assert!(value["payload"].get("clientId").is_none());
545 }
546
547 #[test]
548 fn artifact_host_contract_uses_canonical_wire_types() {
549 let descriptor = ExternalArtifactReadDescriptor {
550 path: "/runtime/artifacts/blob".to_string(),
551 kind: artifact_api::ArtifactKind::Audio,
552 mime_type: "audio/ogg".to_string(),
553 size_bytes: 42,
554 width: None,
555 height: None,
556 duration_millis: Some(1_500),
557 sha256: "a".repeat(64),
558 };
559 let value = serde_json::to_value(descriptor).unwrap();
560 assert_eq!(value["kind"], "AUDIO");
561 assert_eq!(value["mimeType"], "audio/ogg");
562 assert_eq!(value["durationMillis"], 1_500);
563 assert!(value.get("width").is_none());
564
565 let request = ArtifactDeliveryProjectionRequest {
566 grant: "payload.signature".to_string(),
567 expires_at_unix_ms: 123,
568 client_id: "L:client".to_string(),
569 };
570 let value = serde_json::to_value(request).unwrap();
571 assert_eq!(value["expiresAtUnixMs"], 123);
572 assert_eq!(value["clientId"], "L:client");
573 }
574
575 #[test]
576 fn playground_uses_only_the_general_webhook_contract() {
577 let general = serde_json::to_value(ExternalCoreMethod::GeneralWebhookPayload).unwrap();
578 assert_eq!(general, serde_json::json!("GeneralWebhookPayload"));
579 assert!(
580 serde_json::from_value::<ExternalCoreMethod>(serde_json::json!(
581 "PlaygroundWebhookPayload"
582 ))
583 .is_err()
584 );
585 }
586}