Skip to main content

core_api/
external_rpc.rs

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 = 15;
8pub const ARTIFACT_CONTENT_PATH_PREFIX: &str = "/artifact/v1/content/";
9pub const ARTIFACT_UPLOAD_PATH_PREFIX: &str = "/artifact/v1/upload/";
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ExternalCoreRequest {
13    pub id: String,
14    pub method: ExternalCoreMethod,
15    pub payload: Value,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ExternalCoreResponse {
20    pub id: String,
21    pub ok: bool,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub result: Option<Value>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub error: Option<ExternalCoreError>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExternalCoreError {
30    pub code: String,
31    pub message: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub enum ExternalCoreMethod {
36    HandleCoreInput,
37    RegisterLocalAppClient,
38    RegisterNodeConnection,
39    CleanupWsState,
40    UpdateCallbackBase,
41    PutArtifact,
42    OpenArtifact,
43    AuthorizeArtifactRead,
44    AuthorizeArtifactUpload,
45    CommitArtifactUpload,
46    DeviceIdentity,
47    CommissionFingerprint,
48    CommissionPublicKeyBase64,
49    ListMdnsRecords,
50    PollEvents,
51    CompleteEvents,
52    CommissionChallengePayload,
53    PairingStartPayload,
54    SetupStartPayload,
55    GeneralWebhookPayload,
56    InvokeWasmPayload,
57    Health,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HandleCoreInputRequest {
62    pub input: ServiceCoreInput,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HandleCoreInputResponse {
67    pub output: ServiceCoreOutput,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct RegisterLocalAppClientRequest {
72    pub ws_id: String,
73    pub auth_request: AuthRequest,
74    pub session: AuthenticatedSession,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RegisterNodeConnectionRequest {
79    pub ws_id: String,
80    pub session_id: String,
81    pub node_type: String,
82    pub node_id: String,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub host_name: Option<String>,
85    pub tenant_id: String,
86    pub scope_id: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct CleanupWsStateRequest {
91    pub ws_id: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct UpdateCallbackBaseRequest {
96    pub callback_base: String,
97    pub host: String,
98    pub port: u16,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ExternalCoreDeviceIdentity {
104    pub device_id: String,
105    pub device_name: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ListMdnsRecordsRequest {
111    pub port: u16,
112    pub addresses: Vec<String>,
113    pub host_type: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ExternalCoreMdnsRecord {
119    pub service_type: String,
120    pub instance_name: String,
121    pub port: u16,
122    pub properties: HashMap<String, String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct PollExternalCoreEventsRequest {
128    pub consumer_id: String,
129    pub max_events: u16,
130    pub timeout_ms: u64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct PollExternalCoreEventsResponse {
136    pub events: Vec<ExternalCoreEvent>,
137    pub timed_out: bool,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct CompleteExternalCoreEventsRequest {
143    pub consumer_id: String,
144    pub completions: Vec<ExternalCoreEventCompletion>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct CompleteExternalCoreEventsResponse {
150    pub completed_event_ids: Vec<String>,
151    pub missing_event_ids: Vec<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct ExternalCoreEvent {
157    pub event_id: String,
158    pub kind: ExternalCoreEventKind,
159    pub payload: Value,
160    pub expects_response: bool,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "camelCase")]
165pub enum ExternalCoreEventKind {
166    MdnsRecordsChanged,
167    DeliveryRequested,
168    ConnectionControlRequested,
169    HostRuntimeRequest,
170    ServiceAppFacadeRequest,
171    ScopeOwnedDataPurgeRequested,
172    ArtifactDeliveryProjectionRequested,
173    ArtifactUploadProjectionRequested,
174}
175
176/// Messaging-only Core -> Host request. A successful response means delivery was accepted by
177/// the provider implementation, not merely queued in the Core event pump or read by a user.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase", deny_unknown_fields)]
180pub struct MessagingDeliveryRequest {
181    pub tenant_id: String,
182    pub scope_id: String,
183    pub surface_id: String,
184    pub target: String,
185    pub payload: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct MessagingDeliveryResponse {
191    pub outcome: crate::DeliveryOutcome,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct ExternalArtifactRequestContext {
197    pub tenant_id: String,
198    pub scope_id: String,
199    pub actor_user_id: String,
200    pub client_id: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct ExternalPutArtifactRequest {
206    pub context: ExternalArtifactRequestContext,
207    pub artifact: artifact_api::PutArtifactRequest,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct ExternalOpenArtifactRequest {
213    pub context: ExternalArtifactRequestContext,
214    pub uri: String,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase", deny_unknown_fields)]
219pub struct ExternalAuthorizeArtifactReadRequest {
220    pub grant: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225pub struct ExternalAuthorizeArtifactUploadRequest {
226    pub grant: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase", deny_unknown_fields)]
231pub struct ExternalCommitArtifactUploadRequest {
232    pub grant: String,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct ExternalArtifactReadDescriptor {
238    pub path: String,
239    pub kind: artifact_api::ArtifactKind,
240    pub mime_type: String,
241    pub size_bytes: u64,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub width: Option<u32>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub height: Option<u32>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub duration_millis: Option<u64>,
248    pub sha256: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase", deny_unknown_fields)]
253pub struct ExternalArtifactUploadDescriptor {
254    pub upload_id: String,
255    pub path: String,
256    pub kind: artifact_api::ArtifactKind,
257    pub mime_type: String,
258    pub size_bytes: u64,
259    pub sha256: String,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub duration_millis: Option<u64>,
262    pub expires_at_unix_ms: u64,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267pub struct ArtifactDeliveryProjectionRequest {
268    pub grant: String,
269    pub expires_at_unix_ms: u64,
270    pub client_id: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct ArtifactDeliveryProjectionResponse {
276    pub url: String,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase", deny_unknown_fields)]
281pub struct ArtifactUploadProjectionRequest {
282    pub grant: String,
283    pub expires_at_unix_ms: u64,
284    pub client_id: String,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase", deny_unknown_fields)]
289pub struct ArtifactUploadProjectionResponse {
290    pub url: String,
291}
292
293/// An App Facade request delegated by an externally hosted Core to the
294/// application service that owns the target's business logic.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct ExternalServiceAppFacadeRequest {
298    pub target: String,
299    pub tenant_id: String,
300    pub scope_id: String,
301    pub user_id: String,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub client_id: Option<String>,
304    pub payload: Value,
305}
306
307/// A platform capability request emitted by an externally hosted Core.
308///
309/// Core owns the onboarding workflow. The native host owns LAN discovery and
310/// transport to a discovered candidate, so Android can satisfy this contract
311/// without moving application logic into the mobile shell.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct ExternalHostRuntimeRequest {
315    pub method: ExternalHostRuntimeMethod,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub candidate_id: Option<String>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub action: Option<String>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub payload: Option<Value>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325#[serde(rename_all = "camelCase")]
326pub enum ExternalHostRuntimeMethod {
327    Discovery,
328    CandidateRequest,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332#[serde(rename_all = "camelCase")]
333pub struct ScopeOwnedDataPurgeRequest {
334    pub tenant_id: String,
335    pub scope_id: String,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct ExternalCoreEventCompletion {
341    pub event_id: String,
342    pub ok: bool,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub response: Option<Value>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub error: Option<ExternalCoreError>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct HttpPayloadRequest {
351    pub payload: Value,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct EndpointPayloadRequest {
356    pub endpoint_id: String,
357    pub payload: Value,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ExternalCoreRuntimeMetadata {
363    pub backend: String,
364    pub instance_id: String,
365    pub pid: u32,
366    pub state: String,
367    pub started_at: String,
368    pub protocol_version: u32,
369    pub binary_version: String,
370    pub socket_path: String,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct ExternalCoreHealth {
376    pub ok: bool,
377    pub ready: bool,
378    pub backend: String,
379    pub binary_version: String,
380    pub agent_version: String,
381    pub protocol_version: u32,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub instance_id: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub pid: Option<u32>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub state: Option<String>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub started_at: Option<String>,
390    #[serde(default)]
391    pub capabilities: Vec<String>,
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn messaging_delivery_is_a_closed_request_response_contract() {
400        let request = serde_json::json!({"tenantId":"t", "scopeId":"s", "surfaceId":"m",
401            "target":"/chat/event", "payload":"{}"});
402        let decoded: MessagingDeliveryRequest = serde_json::from_value(request.clone()).unwrap();
403        assert_eq!(serde_json::to_value(decoded).unwrap(), request);
404        let mut invalid = request;
405        invalid["unknown"] = serde_json::json!(true);
406        assert!(serde_json::from_value::<MessagingDeliveryRequest>(invalid).is_err());
407        assert!(
408            serde_json::from_value::<MessagingDeliveryResponse>(serde_json::json!({})).is_err()
409        );
410        assert_eq!(
411            serde_json::to_value(ExternalCoreEventKind::DeliveryRequested).unwrap(),
412            "deliveryRequested"
413        );
414    }
415
416    #[test]
417    fn external_events_use_the_v15_wire_shape() {
418        let event = ExternalCoreEvent {
419            event_id: "event-1".to_string(),
420            kind: ExternalCoreEventKind::ScopeOwnedDataPurgeRequested,
421            payload: serde_json::to_value(ScopeOwnedDataPurgeRequest {
422                tenant_id: "tenant-1".to_string(),
423                scope_id: "scope-1".to_string(),
424            })
425            .unwrap(),
426            expects_response: true,
427        };
428
429        let value = serde_json::to_value(event).unwrap();
430        assert_eq!(EXTERNAL_CORE_PROTOCOL_VERSION, 15);
431        assert_eq!(value["kind"], "scopeOwnedDataPurgeRequested");
432        assert_eq!(value["payload"]["tenantId"], "tenant-1");
433        assert_eq!(value["payload"]["scopeId"], "scope-1");
434        assert_eq!(value["expectsResponse"], true);
435
436        let facade = ExternalCoreEvent {
437            event_id: "event-2".to_string(),
438            kind: ExternalCoreEventKind::ServiceAppFacadeRequest,
439            payload: serde_json::to_value(ExternalServiceAppFacadeRequest {
440                target: "/app/messaging/provider/list".to_string(),
441                tenant_id: "tenant-1".to_string(),
442                scope_id: "scope-1".to_string(),
443                user_id: "user-1".to_string(),
444                client_id: None,
445                payload: serde_json::json!({"placement": "local"}),
446            })
447            .unwrap(),
448            expects_response: true,
449        };
450        let value = serde_json::to_value(facade).unwrap();
451        assert_eq!(value["kind"], "serviceAppFacadeRequest");
452        assert_eq!(value["payload"]["target"], "/app/messaging/provider/list");
453        assert_eq!(value["payload"]["userId"], "user-1");
454        assert!(value["payload"].get("clientId").is_none());
455    }
456
457    #[test]
458    fn artifact_host_contract_uses_canonical_wire_types() {
459        let descriptor = ExternalArtifactReadDescriptor {
460            path: "/runtime/artifacts/blob".to_string(),
461            kind: artifact_api::ArtifactKind::Audio,
462            mime_type: "audio/ogg".to_string(),
463            size_bytes: 42,
464            width: None,
465            height: None,
466            duration_millis: Some(1_500),
467            sha256: "a".repeat(64),
468        };
469        let value = serde_json::to_value(descriptor).unwrap();
470        assert_eq!(value["kind"], "AUDIO");
471        assert_eq!(value["mimeType"], "audio/ogg");
472        assert_eq!(value["durationMillis"], 1_500);
473        assert!(value.get("width").is_none());
474
475        let request = ArtifactDeliveryProjectionRequest {
476            grant: "payload.signature".to_string(),
477            expires_at_unix_ms: 123,
478            client_id: "L:client".to_string(),
479        };
480        let value = serde_json::to_value(request).unwrap();
481        assert_eq!(value["expiresAtUnixMs"], 123);
482        assert_eq!(value["clientId"], "L:client");
483    }
484
485    #[test]
486    fn playground_uses_only_the_general_webhook_contract() {
487        let general = serde_json::to_value(ExternalCoreMethod::GeneralWebhookPayload).unwrap();
488        assert_eq!(general, serde_json::json!("GeneralWebhookPayload"));
489        assert!(
490            serde_json::from_value::<ExternalCoreMethod>(serde_json::json!(
491                "PlaygroundWebhookPayload"
492            ))
493            .is_err()
494        );
495    }
496}