Skip to main content

core_api/
lib.rs

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