Skip to main content

core_api/
lib.rs

1#![allow(clippy::derivable_impls, clippy::should_implement_trait)]
2
3mod external_rpc;
4
5pub use external_rpc::*;
6
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10/// Shared MWS transport limits. Both websocket peers must apply these values so
11/// an envelope accepted by one side is never rejected solely due to asymmetric
12/// transport configuration.
13pub const MWS_MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
14pub const MWS_MAX_FRAME_SIZE: usize = 4 * 1024 * 1024;
15pub const MWS_MAX_WRITE_BUFFER_SIZE: usize = 32 * 1024 * 1024;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum ExceptionCode {
19    Unsupported,
20    NullData,
21    ErrorSubscribe,
22    ErrorUnsubscribe,
23    ErrorProcessDataReport,
24    ErrorProcessDataPoll,
25    ErrorLock,
26    ErrorUnlock,
27    FunctionNoImpl,
28    Unreachable,
29    Unauthenticated,
30    Unauthorized,
31    PreconditionFail,
32    SessionExpired,
33    Internal,
34    Unknown,
35    NotFound,
36    AlreadyExists,
37    BadRequest,
38    InvalidWidgetDefinition,
39    DeadlineExceeded,
40    TemporaryUnavailable,
41    ResourceLocked,
42    WsConnectionLost,
43    BillAutomationExceed,
44    BillTokenExceed,
45    MissingContext,
46}
47
48impl ExceptionCode {
49    pub fn from_str(s: &str) -> Self {
50        match s {
51            "UNSUPPORTED" => Self::Unsupported,
52            "NULL_DATA" => Self::NullData,
53            "ERROR_SUBSCRIBE" => Self::ErrorSubscribe,
54            "ERROR_UNSUBSCRIBE" => Self::ErrorUnsubscribe,
55            "ERROR_PROCESS_DATA_REPORT" => Self::ErrorProcessDataReport,
56            "ERROR_PROCESS_DATA_POLL" => Self::ErrorProcessDataPoll,
57            "ERROR_LOCK" => Self::ErrorLock,
58            "ERROR_UNLOCK" => Self::ErrorUnlock,
59            "FUNCTION_NO_IMPL" => Self::FunctionNoImpl,
60            "UNREACHABLE" => Self::Unreachable,
61            "UNAUTHENTICATED" => Self::Unauthenticated,
62            "UNAUTHORIZED" => Self::Unauthorized,
63            "PRECONDITION_FAIL" => Self::PreconditionFail,
64            "SESSION_EXPIRED" => Self::SessionExpired,
65            "INTERNAL" => Self::Internal,
66            "NOT_FOUND" => Self::NotFound,
67            "ALREADY_EXISTS" => Self::AlreadyExists,
68            "BAD_REQUEST" => Self::BadRequest,
69            "INVALID_WIDGET_DEFINITION" => Self::InvalidWidgetDefinition,
70            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
71            "TEMPORARY_UNAVAILABLE" => Self::TemporaryUnavailable,
72            "RESOURCE_LOCKED" => Self::ResourceLocked,
73            "WS_CONNECTION_LOST" => Self::WsConnectionLost,
74            "BILL_AUTOMATION_EXCEED" => Self::BillAutomationExceed,
75            "BILL_TOKEN_EXCEED" => Self::BillTokenExceed,
76            "MISSING_CONTEXT" => Self::MissingContext,
77            _ => Self::Unknown,
78        }
79    }
80
81    pub fn as_str(&self) -> &'static str {
82        match self {
83            Self::Unsupported => "UNSUPPORTED",
84            Self::NullData => "NULL_DATA",
85            Self::ErrorSubscribe => "ERROR_SUBSCRIBE",
86            Self::ErrorUnsubscribe => "ERROR_UNSUBSCRIBE",
87            Self::ErrorProcessDataReport => "ERROR_PROCESS_DATA_REPORT",
88            Self::ErrorProcessDataPoll => "ERROR_PROCESS_DATA_POLL",
89            Self::ErrorLock => "ERROR_LOCK",
90            Self::ErrorUnlock => "ERROR_UNLOCK",
91            Self::FunctionNoImpl => "FUNCTION_NO_IMPL",
92            Self::Unreachable => "UNREACHABLE",
93            Self::Unauthenticated => "UNAUTHENTICATED",
94            Self::Unauthorized => "UNAUTHORIZED",
95            Self::PreconditionFail => "PRECONDITION_FAIL",
96            Self::SessionExpired => "SESSION_EXPIRED",
97            Self::Internal => "INTERNAL",
98            Self::Unknown => "UNKNOWN",
99            Self::NotFound => "NOT_FOUND",
100            Self::AlreadyExists => "ALREADY_EXISTS",
101            Self::BadRequest => "BAD_REQUEST",
102            Self::InvalidWidgetDefinition => "INVALID_WIDGET_DEFINITION",
103            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
104            Self::TemporaryUnavailable => "TEMPORARY_UNAVAILABLE",
105            Self::ResourceLocked => "RESOURCE_LOCKED",
106            Self::WsConnectionLost => "WS_CONNECTION_LOST",
107            Self::BillAutomationExceed => "BILL_AUTOMATION_EXCEED",
108            Self::BillTokenExceed => "BILL_TOKEN_EXCEED",
109            Self::MissingContext => "MISSING_CONTEXT",
110        }
111    }
112}
113
114impl From<ExceptionCode> for String {
115    fn from(value: ExceptionCode) -> Self {
116        value.as_str().to_string()
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
121pub struct ErrorResponse {
122    pub error: String,
123    pub message: String,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub details: Option<serde_json::Value>,
126}
127
128impl ErrorResponse {
129    pub fn new(error_code: impl Into<String>, message: impl Into<String>) -> Self {
130        Self {
131            error: error_code.into(),
132            message: message.into(),
133            details: None,
134        }
135    }
136
137    pub fn with_details(mut self, details: serde_json::Value) -> Self {
138        self.details = Some(details);
139        self
140    }
141}
142
143pub struct ClientIds;
144
145impl ClientIds {
146    pub fn from_cloud(peer_id: &str, ws_id: &str) -> String {
147        format!("C:{}:{}", peer_id, ws_id)
148    }
149
150    pub fn from_local(ws_id: &str) -> String {
151        format!("L:{}", ws_id)
152    }
153
154    pub fn from_telegram(bot_id: &str, chat_id: i64) -> String {
155        format!("M:telegram:{}:{}", bot_id, chat_id)
156    }
157
158    pub fn is_cloud(client_id: &str) -> bool {
159        client_id.starts_with("C:")
160    }
161
162    pub fn is_local(client_id: &str) -> bool {
163        client_id.starts_with("L:")
164    }
165
166    pub fn is_telegram(client_id: &str) -> bool {
167        client_id.starts_with("M:telegram:")
168    }
169
170    pub fn is_messaging(client_id: &str) -> bool {
171        client_id.starts_with("M:")
172    }
173
174    pub fn to_telegram_bot_id(client_id: &str) -> Option<i64> {
175        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
176        parts.get(2)?.parse::<i64>().ok()
177    }
178
179    pub fn to_telegram_chat_id(client_id: &str) -> Option<i64> {
180        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
181        parts.get(3)?.parse::<i64>().ok()
182    }
183
184    pub fn to_peer_id(client_id: &str) -> Option<String> {
185        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
186        parts.get(1).map(|s| s.to_string())
187    }
188
189    pub fn to_device_id(client_id: &str) -> Option<String> {
190        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
191        parts.get(1).map(|s| s.to_string())
192    }
193}
194
195pub struct MwsMessageType;
196
197impl MwsMessageType {
198    pub const HUB_REQ: &'static str = "hrq";
199    pub const HUB_RESP: &'static str = "hrp";
200    pub const HUB_DATA: &'static str = "hd";
201    pub const NODE_REQ: &'static str = "nrq";
202    pub const NODE_RESP: &'static str = "nrp";
203    pub const NODE_DATA: &'static str = "nd";
204    pub const AGENT_REQ: &'static str = "grq";
205    pub const AGENT_RESP: &'static str = "grp";
206    pub const AGENT_DATA: &'static str = "gd";
207    pub const CLOUD_REQ: &'static str = "crq";
208    pub const CLOUD_RESP: &'static str = "crp";
209    pub const CLOUD_DATA: &'static str = "cd";
210    pub const APP_REQ: &'static str = "arq";
211    pub const APP_RESP: &'static str = "arp";
212    pub const SERVER_REQ: &'static str = "srq";
213    pub const SERVER_RESP: &'static str = "srp";
214    pub const APP_DATA: &'static str = "ad";
215    pub const SERVER_DATA: &'static str = "sd";
216    pub const PING: &'static str = "pi";
217    pub const PONG: &'static str = "po";
218}
219
220pub struct MwsSource;
221
222impl MwsSource {
223    pub const IOS: &'static str = "ios";
224    pub const ANDROID: &'static str = "android";
225    pub const WINDOWS: &'static str = "windows";
226    pub const MAC: &'static str = "macos";
227    pub const LINUX: &'static str = "linux";
228    pub const WEB: &'static str = "web";
229    pub const PWA: &'static str = "pwa";
230    pub const MESSAGING: &'static str = "messaging";
231
232    pub fn is_desktop(source: &str) -> bool {
233        matches!(source, Self::MAC | Self::WINDOWS | Self::LINUX)
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
238#[serde(rename_all = "camelCase")]
239pub struct MwsClientInfo {
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub client_id: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub user_id: Option<String>,
244}
245
246impl MwsClientInfo {
247    pub fn new(client_id: String, user_id: String) -> Self {
248        Self {
249            client_id: Some(client_id),
250            user_id: Some(user_id),
251        }
252    }
253
254    pub fn from_ws_id(ws_id: String) -> Self {
255        Self {
256            client_id: Some(ClientIds::from_local(&ws_id)),
257            user_id: None,
258        }
259    }
260
261    pub fn from_client_id(client_id: String) -> Self {
262        Self {
263            client_id: Some(client_id),
264            user_id: None,
265        }
266    }
267
268    pub fn to_client_id(&self) -> String {
269        self.client_id
270            .clone()
271            .unwrap_or_else(|| ClientIds::from_local("unknown"))
272    }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct MwsMessage {
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub scope_id: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub from: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub target: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub sig: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub r#type: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub payload: Option<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub error: Option<ErrorResponse>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub client_info: Option<MwsClientInfo>,
294}
295
296impl MwsMessage {
297    pub fn create(
298        scope_id: Option<String>,
299        target: String,
300        sig: String,
301        payload: String,
302        msg_type: String,
303    ) -> Self {
304        Self {
305            scope_id,
306            target: Some(target),
307            sig: Some(sig),
308            payload: Some(payload),
309            r#type: Some(msg_type),
310            from: None,
311            error: None,
312            client_info: None,
313        }
314    }
315
316    pub fn dummy() -> Self {
317        Self {
318            scope_id: None,
319            from: None,
320            target: None,
321            sig: None,
322            r#type: None,
323            payload: None,
324            error: None,
325            client_info: None,
326        }
327    }
328
329    pub fn server_response(
330        target: String,
331        sig: String,
332        payload: String,
333        client_info: MwsClientInfo,
334    ) -> Self {
335        Self {
336            scope_id: None,
337            from: None,
338            target: Some(target),
339            sig: Some(sig),
340            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
341            payload: Some(payload),
342            error: None,
343            client_info: Some(client_info),
344        }
345    }
346
347    pub fn server_response_error(
348        target: String,
349        sig: String,
350        error: ErrorResponse,
351        client_info: MwsClientInfo,
352    ) -> Self {
353        Self {
354            scope_id: None,
355            from: None,
356            target: Some(target),
357            sig: Some(sig),
358            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
359            payload: None,
360            error: Some(error),
361            client_info: Some(client_info),
362        }
363    }
364
365    pub fn unscoped_server_data(
366        target: String,
367        payload: String,
368        client_info: MwsClientInfo,
369    ) -> Self {
370        Self {
371            scope_id: None,
372            from: None,
373            target: Some(target),
374            sig: None,
375            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
376            payload: Some(payload),
377            error: None,
378            client_info: Some(client_info),
379        }
380    }
381
382    pub fn scoped_server_data(
383        scope_id: String,
384        target: String,
385        payload: String,
386        client_info: Option<MwsClientInfo>,
387    ) -> Self {
388        Self {
389            scope_id: Some(scope_id),
390            from: None,
391            target: Some(target),
392            sig: None,
393            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
394            payload: Some(payload),
395            error: None,
396            client_info,
397        }
398    }
399
400    pub fn hub_response(
401        target: String,
402        sig: String,
403        payload: String,
404        client_info: MwsClientInfo,
405    ) -> Self {
406        Self {
407            scope_id: None,
408            from: None,
409            target: Some(target),
410            sig: Some(sig),
411            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
412            payload: Some(payload),
413            error: None,
414            client_info: Some(client_info),
415        }
416    }
417
418    pub fn hub_response_error(
419        target: String,
420        sig: String,
421        error: ErrorResponse,
422        client_info: MwsClientInfo,
423    ) -> Self {
424        Self {
425            scope_id: None,
426            from: None,
427            target: Some(target),
428            sig: Some(sig),
429            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
430            payload: None,
431            error: Some(error),
432            client_info: Some(client_info),
433        }
434    }
435
436    pub fn hub_request(
437        scope_id: Option<String>,
438        target: String,
439        sig: String,
440        payload: String,
441    ) -> Self {
442        Self {
443            scope_id,
444            from: None,
445            target: Some(target),
446            sig: Some(sig),
447            r#type: Some(MwsMessageType::HUB_REQ.to_string()),
448            payload: Some(payload),
449            error: None,
450            client_info: None,
451        }
452    }
453
454    pub fn hub_data(
455        scope_id: Option<String>,
456        target: String,
457        sig: String,
458        payload: String,
459    ) -> Self {
460        Self {
461            scope_id,
462            from: None,
463            target: Some(target),
464            sig: Some(sig),
465            r#type: Some(MwsMessageType::HUB_DATA.to_string()),
466            payload: Some(payload),
467            error: None,
468            client_info: None,
469        }
470    }
471
472    pub fn set_from(&mut self, from: String) {
473        self.from = Some(from);
474    }
475
476    pub fn set_target(&mut self, target: String) {
477        self.target = Some(target);
478    }
479
480    pub fn set_sig(&mut self, sig: String) {
481        self.sig = Some(sig);
482    }
483
484    pub fn set_payload(&mut self, payload: String) {
485        self.payload = Some(payload);
486    }
487
488    pub fn set_type(&mut self, msg_type: String) {
489        self.r#type = Some(msg_type);
490    }
491
492    pub fn set_client_info(&mut self, client_info: MwsClientInfo) {
493        self.client_info = Some(client_info);
494    }
495}
496
497impl Default for MwsMessage {
498    fn default() -> Self {
499        Self::dummy()
500    }
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct NodeAuthRequest {
506    pub hub_id: String,
507    pub token: String,
508    pub node_type: String,
509    #[serde(default)]
510    pub node_id: String,
511    #[serde(default)]
512    pub instance_id: String,
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub scope_id: Option<String>,
515    pub host_id: String,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub host_name: Option<String>,
518    pub fingerprint: String,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(rename_all = "camelCase")]
523pub struct NodeAuthResponse {
524    pub ok: bool,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub session_id: Option<String>,
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub hub_id: Option<String>,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub tenant_id: Option<String>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub scope_id: Option<String>,
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub error: Option<String>,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub error_code: Option<String>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub recovery_action: Option<String>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub node_id: Option<String>,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(rename_all = "camelCase")]
545pub struct NodeTokenIssueRequest {
546    pub transaction_id: String,
547    pub node_type: String,
548    pub candidate_host_id: String,
549    pub candidate_fingerprint: String,
550    pub challenge: NodeChallengeEvidence,
551}
552
553#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
554#[serde(rename_all = "camelCase")]
555pub enum NodeInstancePolicy {
556    Multiple,
557    Singleton,
558    Shared,
559}
560
561impl NodeInstancePolicy {
562    pub fn as_str(self) -> &'static str {
563        match self {
564            Self::Multiple => "multiple",
565            Self::Singleton => "singleton",
566            Self::Shared => "shared",
567        }
568    }
569}
570
571impl Default for NodeInstancePolicy {
572    fn default() -> Self {
573        Self::Multiple
574    }
575}
576
577impl std::fmt::Display for NodeInstancePolicy {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        f.write_str(self.as_str())
580    }
581}
582
583impl FromStr for NodeInstancePolicy {
584    type Err = String;
585
586    fn from_str(value: &str) -> Result<Self, Self::Err> {
587        match value.trim() {
588            "multiple" | "Multiple" => Ok(Self::Multiple),
589            "singleton" | "Singleton" => Ok(Self::Singleton),
590            "shared" | "Shared" => Ok(Self::Shared),
591            other => Err(format!("unsupported node instance policy: {other}")),
592        }
593    }
594}
595
596#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
597#[serde(rename_all = "camelCase")]
598pub enum NodeBindingStatus {
599    Pending,
600    Active,
601    Revoked,
602    Expired,
603    Failed,
604}
605
606impl NodeBindingStatus {
607    pub fn as_str(self) -> &'static str {
608        match self {
609            Self::Pending => "pending",
610            Self::Active => "active",
611            Self::Revoked => "revoked",
612            Self::Expired => "expired",
613            Self::Failed => "failed",
614        }
615    }
616}
617
618impl Default for NodeBindingStatus {
619    fn default() -> Self {
620        Self::Active
621    }
622}
623
624impl std::fmt::Display for NodeBindingStatus {
625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        f.write_str(self.as_str())
627    }
628}
629
630impl FromStr for NodeBindingStatus {
631    type Err = String;
632
633    fn from_str(value: &str) -> Result<Self, Self::Err> {
634        match value.trim() {
635            "pending" | "Pending" => Ok(Self::Pending),
636            "active" | "Active" => Ok(Self::Active),
637            "revoked" | "Revoked" => Ok(Self::Revoked),
638            "expired" | "Expired" => Ok(Self::Expired),
639            "failed" | "Failed" => Ok(Self::Failed),
640            other => Err(format!("unsupported node binding status: {other}")),
641        }
642    }
643}
644
645pub const NODE_ONBOARDING_CHALLENGE_PROTOCOL: &str = "meow.node.onboarding.challenge";
646pub const NODE_ONBOARDING_CHALLENGE_AUDIENCE: &str = "meow-core:node-onboarding";
647pub const NODE_ONBOARDING_CHALLENGE_ALGORITHM: &str = "Ed25519";
648pub const NODE_ONBOARDING_TOKEN_ISSUER_PREFIX: &str = "meow-core:hub:";
649pub const NODE_ONBOARDING_TOKEN_AUDIENCE: &str = "meow-node:onboarding";
650pub const NODE_ONBOARDING_ES256_ALGORITHM: &str = "ES256";
651pub const NODE_ONBOARDING_TRANSACTION_TTL_SECONDS: u64 = 5 * 60;
652pub const NODE_ONBOARDING_START_TARGET: &str = "/identity/node/onboarding/start";
653pub const NODE_TOKEN_ISSUE_TARGET: &str = "/identity/node/issue";
654pub const NODE_TOKEN_REVOKE_TARGET: &str = "/identity/node/revoke";
655pub const NODE_TOKEN_LIST_TARGET: &str = "/identity/node/list";
656pub const HUB_CONNECTION_PROOF_TARGET: &str = "/identity/hub/prove";
657pub const HUB_CONNECTION_PROOF_PROTOCOL: &str = "meow.hub.connection.proof.v1";
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
660#[serde(rename_all = "camelCase", deny_unknown_fields)]
661pub struct NodeOnboardingStartRequest {
662    pub node_type: String,
663    pub candidate_host_id: String,
664    pub candidate_fingerprint: String,
665    pub idempotency_key: String,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
669#[serde(rename_all = "camelCase", deny_unknown_fields)]
670pub struct NodeOnboardingStartResponse {
671    pub transaction_id: String,
672    pub nonce: String,
673    pub expires_at: i64,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677#[serde(rename_all = "camelCase")]
678pub struct HubConnectionProofRequest {
679    pub protocol: String,
680    pub hub_id: String,
681    pub tenant_id: String,
682    pub scope_id: String,
683    pub nonce: String,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
687#[serde(rename_all = "camelCase")]
688pub struct HubConnectionProofResponse {
689    pub protocol: String,
690    pub hub_id: String,
691    pub tenant_id: String,
692    pub scope_id: String,
693    pub nonce: String,
694    pub key_id: String,
695    pub signature: String,
696}
697
698/// Canonical, domain-separated bytes signed by the Hub for one physical WS connection.
699/// Length prefixes keep the encoding unambiguous without relying on JSON object ordering.
700pub fn hub_connection_proof_signing_payload(request: &HubConnectionProofRequest) -> Vec<u8> {
701    let fields = [
702        request.protocol.as_str(),
703        request.hub_id.as_str(),
704        request.tenant_id.as_str(),
705        request.scope_id.as_str(),
706        request.nonce.as_str(),
707    ];
708    let mut payload = Vec::new();
709    for field in fields {
710        payload.extend_from_slice(&(field.len() as u64).to_be_bytes());
711        payload.extend_from_slice(field.as_bytes());
712    }
713    payload
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
717#[serde(rename_all = "camelCase")]
718pub struct NodeChallengeEvidence {
719    pub protocol: String,
720    pub algorithm: String,
721    pub payload: String,
722    pub signature: String,
723    pub public_key: String,
724    pub fingerprint: String,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728#[serde(rename_all = "camelCase")]
729pub struct NodeChallengePayload {
730    pub protocol: String,
731    pub aud: String,
732    pub nonce: String,
733    pub scope_id: String,
734    pub node_type: String,
735    pub host_id: String,
736    pub fingerprint: String,
737    pub service_instance_id: String,
738    pub instance_policy: NodeInstancePolicy,
739    pub instance_slot: String,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[serde(rename_all = "camelCase")]
744pub struct NodeTokenIssueResponse {
745    pub token: String,
746    pub node_id: String,
747    pub expires_in: i64,
748    pub hub_id: String,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct NodeTokenRevokeRequest {
754    pub node_type: String,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub node_id: Option<String>,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
760#[serde(rename_all = "camelCase")]
761pub struct NodeTokenRevokeResponse {
762    pub success: bool,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize)]
766#[serde(rename_all = "camelCase")]
767pub struct NodeTokenListItem {
768    pub node_id: String,
769    pub node_type: String,
770    pub hub_id: String,
771    pub host_id: String,
772    pub fingerprint: String,
773    pub service_instance_id: String,
774    pub instance_policy: NodeInstancePolicy,
775    pub instance_slot: String,
776    pub status: NodeBindingStatus,
777    pub issued_at: i64,
778    pub expires_at: i64,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize)]
782#[serde(rename_all = "camelCase")]
783pub struct NodeTokenListResponse {
784    #[serde(default)]
785    pub nodes: Vec<NodeTokenListItem>,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789#[serde(rename_all = "camelCase")]
790pub struct NodeInstanceListItem {
791    pub node_id: String,
792    pub node_type: String,
793    pub hub_id: String,
794    pub host_id: String,
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub host_name: Option<String>,
797    pub fingerprint: String,
798    pub service_instance_id: String,
799    pub instance_policy: NodeInstancePolicy,
800    pub instance_slot: String,
801    pub binding_status: NodeBindingStatus,
802    pub issued_at: i64,
803    pub expires_at: i64,
804    pub connected: bool,
805    pub runtime_status: String,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(rename_all = "camelCase")]
810pub struct NodeInstanceListResponse {
811    #[serde(default)]
812    pub instances: Vec<NodeInstanceListItem>,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct AuthenticatedSession {
818    pub tenant_id: String,
819    pub scope_id: String,
820    pub user_id: String,
821    pub is_test: bool,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
825#[serde(rename_all = "camelCase")]
826pub struct Image {
827    #[serde(skip_serializing_if = "Option::is_none")]
828    pub url: Option<String>,
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub base64: Option<String>,
831    #[serde(skip_serializing_if = "Option::is_none")]
832    pub mime_type: Option<String>,
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub created_at: Option<i64>,
835}
836
837impl Image {
838    pub fn from_base64(base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
839        Self {
840            base64: Some(base64.into()),
841            mime_type: Some(mime_type.into()),
842            ..Self::default()
843        }
844    }
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
848#[serde(rename_all = "camelCase")]
849pub struct ChatToolCallFunction {
850    pub name: String,
851    pub arguments: String,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
855#[serde(rename_all = "camelCase")]
856pub struct ChatToolCall {
857    pub id: String,
858    #[serde(rename = "type")]
859    pub call_type: String,
860    pub function: ChatToolCallFunction,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, Default)]
864#[serde(rename_all = "camelCase")]
865pub struct ChatMessage {
866    pub role: String,
867    pub content: String,
868    #[serde(default, skip_serializing_if = "Vec::is_empty")]
869    pub images: Vec<Image>,
870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
871    pub tool_calls: Vec<ChatToolCall>,
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub tool_call_id: Option<String>,
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub name: Option<String>,
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize, Default)]
879#[serde(rename_all = "camelCase")]
880pub struct LlmGenerationOptions {
881    #[serde(default)]
882    pub temperature: Option<f32>,
883    #[serde(default)]
884    pub max_tokens: Option<u32>,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize, Default)]
888#[serde(rename_all = "camelCase")]
889pub struct LlmCompleteRequest {
890    #[serde(default)]
891    pub use_case: Option<String>,
892    #[serde(default)]
893    pub mode: Option<String>,
894    pub messages: Vec<ChatMessage>,
895    #[serde(default)]
896    pub tools: Option<serde_json::Value>,
897    #[serde(default)]
898    pub provider: Option<serde_json::Value>,
899    #[serde(default)]
900    pub response_format: Option<serde_json::Value>,
901    #[serde(default)]
902    pub options: LlmGenerationOptions,
903    #[serde(default)]
904    pub temperature: Option<f32>,
905    #[serde(default)]
906    pub max_tokens: Option<u32>,
907}
908
909#[derive(Debug, Clone, Serialize, Deserialize, Default)]
910#[serde(rename_all = "camelCase")]
911pub struct LlmRouteInfo {
912    pub provider: String,
913    pub model: String,
914    #[serde(default)]
915    pub mode: Option<String>,
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize, Default)]
919#[serde(rename_all = "camelCase")]
920pub struct LlmCompleteResponse {
921    pub content: String,
922    #[serde(default)]
923    pub tool_calls: Vec<ProviderToolCall>,
924    #[serde(default)]
925    pub route: Option<LlmRouteInfo>,
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
929#[serde(rename_all = "camelCase")]
930pub struct ProviderToolCall {
931    pub id: String,
932    pub name: String,
933    pub arguments_json: String,
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
937#[serde(rename_all = "snake_case")]
938pub enum ScopeAccessRequirement {
939    Member,
940    Owner,
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
944#[serde(rename_all = "camelCase", deny_unknown_fields)]
945pub struct ScopeAuthorizationRequest {
946    pub tenant_id: String,
947    pub scope_id: String,
948    pub user_id: String,
949    pub requirement: ScopeAccessRequirement,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
953pub enum ServiceCoreInput {
954    ClientAuth {
955        message: MwsMessage,
956        ws_id: String,
957        scope_id: String,
958    },
959    ClientRequest {
960        target: String,
961        payload: String,
962        ws_id: String,
963        tenant_id: String,
964        scope_id: String,
965        user_id: String,
966        is_test: bool,
967        #[serde(default, skip_serializing_if = "Option::is_none")]
968        surface_id: Option<String>,
969    },
970    /// Invokes the canonical Conversation application boundary without a
971    /// websocket/client transport identity.
972    ConversationRequest {
973        target: String,
974        payload: String,
975        tenant_id: String,
976        scope_id: String,
977        actor_user_id: String,
978        surface_id: String,
979        is_test: bool,
980    },
981    /// Authorizes a trusted headless actor before a non-Conversation operation
982    /// such as binding a shared messaging surface.
983    ScopeAuthorization {
984        request: ScopeAuthorizationRequest,
985    },
986    NodeAuth {
987        request: NodeAuthRequest,
988    },
989    HubConnectionProof {
990        request: HubConnectionProofRequest,
991    },
992    NodeRequest {
993        target: String,
994        payload: String,
995        tenant_id: String,
996        scope_id: String,
997        node_type: String,
998        instance_id: String,
999    },
1000    BackendStatusChanged {
1001        tenant_id: String,
1002        scope_id: String,
1003        target: String,
1004        payload: String,
1005    },
1006    ClientResponse {
1007        message: MwsMessage,
1008    },
1009    NodeResponse {
1010        message: MwsMessage,
1011    },
1012    IssueLocalSessionToken {
1013        tenant_id: String,
1014        scope_id: String,
1015        user_id: String,
1016        app_client_id: String,
1017    },
1018    SetLocalAppClientFocus {
1019        ws_id: String,
1020        focused: bool,
1021    },
1022    RefreshLocalAppClient {
1023        ws_id: String,
1024    },
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub enum ServiceCoreResponse {
1029    ClientAuth {
1030        session: Option<AuthenticatedSession>,
1031        response: MwsMessage,
1032    },
1033    ClientRequest {
1034        response_payload: Option<String>,
1035        client_info: MwsClientInfo,
1036    },
1037    ConversationRequest {
1038        response_payload: Option<String>,
1039    },
1040    ScopeAuthorization {
1041        authorized: bool,
1042    },
1043    NodeAuth(NodeAuthResponse),
1044    HubConnectionProof(HubConnectionProofResponse),
1045    NodeRequest {
1046        response_payload: Option<String>,
1047    },
1048    LocalSessionToken(String),
1049    Ack {
1050        handled: bool,
1051    },
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1055pub enum ServiceCoreEffect {
1056    Local {
1057        client_id: String,
1058        message: MwsMessage,
1059    },
1060    Bridge {
1061        tenant_id: String,
1062        scope_id: String,
1063        message: MwsMessage,
1064    },
1065    Node {
1066        connection_key: String,
1067        message: MwsMessage,
1068    },
1069    NodeDisconnect {
1070        connection_key: String,
1071    },
1072    Surface {
1073        tenant_id: String,
1074        scope_id: String,
1075        surface_id: String,
1076        target: String,
1077        payload: String,
1078    },
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1082pub struct ServiceCoreOutput {
1083    #[serde(skip_serializing_if = "Option::is_none")]
1084    pub response: Option<ServiceCoreResponse>,
1085    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1086    pub effects: Vec<ServiceCoreEffect>,
1087}
1088
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090#[serde(rename_all = "camelCase")]
1091pub struct UserSetting {
1092    #[serde(default)]
1093    pub script_mode: bool,
1094    #[serde(default)]
1095    pub debug_mode: bool,
1096    #[serde(default)]
1097    pub eng_account: bool,
1098}
1099
1100impl UserSetting {
1101    pub fn new() -> Self {
1102        Self {
1103            script_mode: false,
1104            debug_mode: false,
1105            eng_account: false,
1106        }
1107    }
1108}
1109
1110impl Default for UserSetting {
1111    fn default() -> Self {
1112        Self::new()
1113    }
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1117#[serde(rename_all = "camelCase")]
1118pub struct UserInfo {
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    pub id: Option<String>,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    pub name: Option<String>,
1123    #[serde(skip_serializing_if = "Option::is_none")]
1124    pub email: Option<String>,
1125    #[serde(skip_serializing_if = "Option::is_none")]
1126    pub setting: Option<UserSetting>,
1127    #[serde(default)]
1128    pub eng: bool,
1129}
1130
1131impl UserInfo {
1132    pub fn new(id: String) -> Self {
1133        Self {
1134            id: Some(id),
1135            name: None,
1136            email: None,
1137            setting: None,
1138            eng: false,
1139        }
1140    }
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize)]
1144#[serde(rename_all = "camelCase")]
1145pub struct ScopeMember {
1146    pub id: Option<String>,
1147    pub email: Option<String>,
1148    pub name: Option<String>,
1149    pub pending: bool,
1150    pub role: Option<String>,
1151}
1152
1153impl Default for ScopeMember {
1154    fn default() -> Self {
1155        Self {
1156            id: None,
1157            email: None,
1158            name: None,
1159            pending: false,
1160            role: None,
1161        }
1162    }
1163}
1164
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1166#[serde(rename_all = "camelCase")]
1167pub struct ScopeInfo {
1168    pub name: Option<String>,
1169    pub id: Option<String>,
1170    pub pending: bool,
1171    pub members: Option<Vec<ScopeMember>>,
1172    pub execution_env: Option<String>,
1173    pub mode: Option<String>,
1174    pub connection_mode: Option<String>,
1175    pub agent_mode: Option<String>,
1176    pub default_active: bool,
1177    #[serde(default)]
1178    pub is_test: bool,
1179}
1180
1181impl Default for ScopeInfo {
1182    fn default() -> Self {
1183        Self {
1184            name: None,
1185            id: None,
1186            pending: false,
1187            members: None,
1188            execution_env: None,
1189            mode: None,
1190            connection_mode: None,
1191            agent_mode: None,
1192            default_active: false,
1193            is_test: false,
1194        }
1195    }
1196}
1197
1198#[derive(Debug, Clone, Serialize, Deserialize)]
1199#[serde(rename_all = "camelCase")]
1200pub struct AuthConfig {
1201    pub tenant_id: String,
1202    pub heartbeat_interval: i32,
1203    pub command_timeout: i32,
1204    pub user_info: UserInfo,
1205    pub scope: ScopeInfo,
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub hub_id: Option<String>,
1208    #[serde(skip_serializing_if = "Option::is_none")]
1209    pub jwt_token: Option<String>,
1210}
1211
1212impl AuthConfig {
1213    pub fn new(
1214        tenant_id: String,
1215        heartbeat_interval: i32,
1216        command_timeout: i32,
1217        user_info: UserInfo,
1218        scope: ScopeInfo,
1219    ) -> Self {
1220        Self {
1221            tenant_id,
1222            heartbeat_interval,
1223            command_timeout,
1224            user_info,
1225            scope,
1226            hub_id: None,
1227            jwt_token: None,
1228        }
1229    }
1230}
1231
1232impl Default for AuthConfig {
1233    fn default() -> Self {
1234        Self {
1235            tenant_id: String::new(),
1236            heartbeat_interval: 240,
1237            command_timeout: 10,
1238            user_info: UserInfo::new(String::new()),
1239            scope: ScopeInfo::default(),
1240            hub_id: None,
1241            jwt_token: None,
1242        }
1243    }
1244}
1245
1246#[derive(Debug, Clone, Serialize, Deserialize)]
1247#[serde(rename_all = "camelCase")]
1248pub struct AuthRequest {
1249    pub token: String,
1250    pub source: String,
1251    pub scope_id: String,
1252    pub device_id: String,
1253    pub client_source: String,
1254    #[serde(default)]
1255    pub tenant_id: Option<String>,
1256    #[serde(default)]
1257    pub hub_id: Option<String>,
1258}
1259
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261#[serde(rename_all = "camelCase")]
1262pub struct HubMdnsInstanceRecord {
1263    pub tenant_id: String,
1264    pub scope_id: String,
1265    pub hub_id: String,
1266    pub scope_name: String,
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::{
1272        hub_connection_proof_signing_payload, HubConnectionProofRequest, MwsMessage,
1273        MwsMessageType, NodeOnboardingStartRequest, HUB_CONNECTION_PROOF_PROTOCOL,
1274    };
1275
1276    #[test]
1277    fn hub_connection_proof_payload_is_unambiguous_and_nonce_bound() {
1278        let request = HubConnectionProofRequest {
1279            protocol: HUB_CONNECTION_PROOF_PROTOCOL.to_string(),
1280            hub_id: "hub-1".to_string(),
1281            tenant_id: "tenant-1".to_string(),
1282            scope_id: "scope-1".to_string(),
1283            nonce: "nonce-1".to_string(),
1284        };
1285        let payload = hub_connection_proof_signing_payload(&request);
1286        let mut changed = request.clone();
1287        changed.nonce = "nonce-2".to_string();
1288
1289        assert_ne!(payload, hub_connection_proof_signing_payload(&changed));
1290        assert!(payload.starts_with(&(HUB_CONNECTION_PROOF_PROTOCOL.len() as u64).to_be_bytes()));
1291    }
1292
1293    #[test]
1294    fn scoped_server_data_builds_scope_envelope() {
1295        let message = MwsMessage::scoped_server_data(
1296            "scope-1".to_string(),
1297            "/dialog".to_string(),
1298            "{}".to_string(),
1299            None,
1300        );
1301
1302        assert_eq!(message.scope_id.as_deref(), Some("scope-1"));
1303        assert_eq!(message.target.as_deref(), Some("/dialog"));
1304        assert_eq!(message.r#type.as_deref(), Some(MwsMessageType::SERVER_DATA));
1305        assert_eq!(message.payload.as_deref(), Some("{}"));
1306        assert!(message.client_info.is_none());
1307    }
1308
1309    #[test]
1310    fn node_onboarding_start_contract_uses_camel_case_and_rejects_unknown_fields() {
1311        let request = NodeOnboardingStartRequest {
1312            node_type: "matter".to_string(),
1313            candidate_host_id: "host-1".to_string(),
1314            candidate_fingerprint: "sha256:abc".to_string(),
1315            idempotency_key: "attempt-1".to_string(),
1316        };
1317
1318        let json = serde_json::to_value(&request).expect("serialize start request");
1319        assert_eq!(json["candidateHostId"], "host-1");
1320        assert_eq!(json["idempotencyKey"], "attempt-1");
1321
1322        let invalid = serde_json::json!({
1323            "nodeType": "matter",
1324            "candidateHostId": "host-1",
1325            "candidateFingerprint": "sha256:abc",
1326            "idempotencyKey": "attempt-1",
1327            "nonce": "caller-must-not-supply-this"
1328        });
1329        assert!(serde_json::from_value::<NodeOnboardingStartRequest>(invalid).is_err());
1330    }
1331}