Skip to main content

core_api/
lib.rs

1#![allow(clippy::derivable_impls, clippy::should_implement_trait)]
2
3mod external_rpc;
4mod node_service;
5
6pub use external_rpc::*;
7pub use node_service::*;
8
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12/// Shared MWS transport limits. Both websocket peers must apply these values so
13/// an envelope accepted by one side is never rejected solely due to asymmetric
14/// transport configuration.
15pub const MWS_MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
16pub const MWS_MAX_FRAME_SIZE: usize = 4 * 1024 * 1024;
17pub const MWS_MAX_WRITE_BUFFER_SIZE: usize = 32 * 1024 * 1024;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum ExceptionCode {
21    Unsupported,
22    NullData,
23    ErrorSubscribe,
24    ErrorUnsubscribe,
25    ErrorProcessDataReport,
26    ErrorProcessDataPoll,
27    ErrorLock,
28    ErrorUnlock,
29    FunctionNoImpl,
30    Unreachable,
31    Unauthenticated,
32    Unauthorized,
33    PreconditionFail,
34    SessionExpired,
35    Internal,
36    Unknown,
37    NotFound,
38    AlreadyExists,
39    BadRequest,
40    InvalidWidgetDefinition,
41    DeadlineExceeded,
42    TemporaryUnavailable,
43    ResourceLocked,
44    WsConnectionLost,
45    BillAutomationExceed,
46    BillTokenExceed,
47    MissingContext,
48}
49
50impl ExceptionCode {
51    pub fn from_str(s: &str) -> Self {
52        match s {
53            "UNSUPPORTED" => Self::Unsupported,
54            "NULL_DATA" => Self::NullData,
55            "ERROR_SUBSCRIBE" => Self::ErrorSubscribe,
56            "ERROR_UNSUBSCRIBE" => Self::ErrorUnsubscribe,
57            "ERROR_PROCESS_DATA_REPORT" => Self::ErrorProcessDataReport,
58            "ERROR_PROCESS_DATA_POLL" => Self::ErrorProcessDataPoll,
59            "ERROR_LOCK" => Self::ErrorLock,
60            "ERROR_UNLOCK" => Self::ErrorUnlock,
61            "FUNCTION_NO_IMPL" => Self::FunctionNoImpl,
62            "UNREACHABLE" => Self::Unreachable,
63            "UNAUTHENTICATED" => Self::Unauthenticated,
64            "UNAUTHORIZED" => Self::Unauthorized,
65            "PRECONDITION_FAIL" => Self::PreconditionFail,
66            "SESSION_EXPIRED" => Self::SessionExpired,
67            "INTERNAL" => Self::Internal,
68            "NOT_FOUND" => Self::NotFound,
69            "ALREADY_EXISTS" => Self::AlreadyExists,
70            "BAD_REQUEST" => Self::BadRequest,
71            "INVALID_WIDGET_DEFINITION" => Self::InvalidWidgetDefinition,
72            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
73            "TEMPORARY_UNAVAILABLE" => Self::TemporaryUnavailable,
74            "RESOURCE_LOCKED" => Self::ResourceLocked,
75            "WS_CONNECTION_LOST" => Self::WsConnectionLost,
76            "BILL_AUTOMATION_EXCEED" => Self::BillAutomationExceed,
77            "BILL_TOKEN_EXCEED" => Self::BillTokenExceed,
78            "MISSING_CONTEXT" => Self::MissingContext,
79            _ => Self::Unknown,
80        }
81    }
82
83    pub fn as_str(&self) -> &'static str {
84        match self {
85            Self::Unsupported => "UNSUPPORTED",
86            Self::NullData => "NULL_DATA",
87            Self::ErrorSubscribe => "ERROR_SUBSCRIBE",
88            Self::ErrorUnsubscribe => "ERROR_UNSUBSCRIBE",
89            Self::ErrorProcessDataReport => "ERROR_PROCESS_DATA_REPORT",
90            Self::ErrorProcessDataPoll => "ERROR_PROCESS_DATA_POLL",
91            Self::ErrorLock => "ERROR_LOCK",
92            Self::ErrorUnlock => "ERROR_UNLOCK",
93            Self::FunctionNoImpl => "FUNCTION_NO_IMPL",
94            Self::Unreachable => "UNREACHABLE",
95            Self::Unauthenticated => "UNAUTHENTICATED",
96            Self::Unauthorized => "UNAUTHORIZED",
97            Self::PreconditionFail => "PRECONDITION_FAIL",
98            Self::SessionExpired => "SESSION_EXPIRED",
99            Self::Internal => "INTERNAL",
100            Self::Unknown => "UNKNOWN",
101            Self::NotFound => "NOT_FOUND",
102            Self::AlreadyExists => "ALREADY_EXISTS",
103            Self::BadRequest => "BAD_REQUEST",
104            Self::InvalidWidgetDefinition => "INVALID_WIDGET_DEFINITION",
105            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
106            Self::TemporaryUnavailable => "TEMPORARY_UNAVAILABLE",
107            Self::ResourceLocked => "RESOURCE_LOCKED",
108            Self::WsConnectionLost => "WS_CONNECTION_LOST",
109            Self::BillAutomationExceed => "BILL_AUTOMATION_EXCEED",
110            Self::BillTokenExceed => "BILL_TOKEN_EXCEED",
111            Self::MissingContext => "MISSING_CONTEXT",
112        }
113    }
114}
115
116impl From<ExceptionCode> for String {
117    fn from(value: ExceptionCode) -> Self {
118        value.as_str().to_string()
119    }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
123pub struct ErrorResponse {
124    pub error: String,
125    pub message: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub details: Option<serde_json::Value>,
128}
129
130impl ErrorResponse {
131    pub fn new(error_code: impl Into<String>, message: impl Into<String>) -> Self {
132        Self {
133            error: error_code.into(),
134            message: message.into(),
135            details: None,
136        }
137    }
138
139    pub fn with_details(mut self, details: serde_json::Value) -> Self {
140        self.details = Some(details);
141        self
142    }
143}
144
145pub struct ClientIds;
146
147impl ClientIds {
148    pub fn from_cloud(peer_id: &str, ws_id: &str) -> String {
149        format!("C:{}:{}", peer_id, ws_id)
150    }
151
152    pub fn from_local(ws_id: &str) -> String {
153        format!("L:{}", ws_id)
154    }
155
156    pub fn from_telegram(bot_id: &str, chat_id: i64) -> String {
157        format!("M:telegram:{}:{}", bot_id, chat_id)
158    }
159
160    pub fn is_cloud(client_id: &str) -> bool {
161        client_id.starts_with("C:")
162    }
163
164    pub fn is_local(client_id: &str) -> bool {
165        client_id.starts_with("L:")
166    }
167
168    pub fn is_telegram(client_id: &str) -> bool {
169        client_id.starts_with("M:telegram:")
170    }
171
172    pub fn is_messaging(client_id: &str) -> bool {
173        client_id.starts_with("M:")
174    }
175
176    pub fn to_telegram_bot_id(client_id: &str) -> Option<i64> {
177        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
178        parts.get(2)?.parse::<i64>().ok()
179    }
180
181    pub fn to_telegram_chat_id(client_id: &str) -> Option<i64> {
182        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
183        parts.get(3)?.parse::<i64>().ok()
184    }
185
186    pub fn to_peer_id(client_id: &str) -> Option<String> {
187        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
188        parts.get(1).map(|s| s.to_string())
189    }
190
191    pub fn to_device_id(client_id: &str) -> Option<String> {
192        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
193        parts.get(1).map(|s| s.to_string())
194    }
195}
196
197pub struct MwsMessageType;
198
199impl MwsMessageType {
200    pub const HUB_REQ: &'static str = "hrq";
201    pub const HUB_RESP: &'static str = "hrp";
202    pub const HUB_DATA: &'static str = "hd";
203    pub const NODE_REQ: &'static str = "nrq";
204    pub const NODE_RESP: &'static str = "nrp";
205    pub const NODE_DATA: &'static str = "nd";
206    pub const AGENT_REQ: &'static str = "grq";
207    pub const AGENT_RESP: &'static str = "grp";
208    pub const AGENT_DATA: &'static str = "gd";
209    pub const CLOUD_REQ: &'static str = "crq";
210    pub const CLOUD_RESP: &'static str = "crp";
211    pub const CLOUD_DATA: &'static str = "cd";
212    pub const APP_REQ: &'static str = "arq";
213    pub const APP_RESP: &'static str = "arp";
214    pub const SERVER_REQ: &'static str = "srq";
215    pub const SERVER_RESP: &'static str = "srp";
216    pub const APP_DATA: &'static str = "ad";
217    pub const SERVER_DATA: &'static str = "sd";
218    pub const PING: &'static str = "pi";
219    pub const PONG: &'static str = "po";
220}
221
222pub struct MwsSource;
223
224impl MwsSource {
225    pub const IOS: &'static str = "ios";
226    pub const ANDROID: &'static str = "android";
227    pub const WINDOWS: &'static str = "windows";
228    pub const MAC: &'static str = "macos";
229    pub const LINUX: &'static str = "linux";
230    pub const WEB: &'static str = "web";
231    pub const PWA: &'static str = "pwa";
232    pub const MESSAGING: &'static str = "messaging";
233
234    pub fn is_desktop(source: &str) -> bool {
235        matches!(source, Self::MAC | Self::WINDOWS | Self::LINUX)
236    }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
240#[serde(rename_all = "camelCase")]
241pub struct MwsClientInfo {
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub client_id: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub user_id: Option<String>,
246}
247
248impl MwsClientInfo {
249    pub fn new(client_id: String, user_id: String) -> Self {
250        Self {
251            client_id: Some(client_id),
252            user_id: Some(user_id),
253        }
254    }
255
256    pub fn from_ws_id(ws_id: String) -> Self {
257        Self {
258            client_id: Some(ClientIds::from_local(&ws_id)),
259            user_id: None,
260        }
261    }
262
263    pub fn from_client_id(client_id: String) -> Self {
264        Self {
265            client_id: Some(client_id),
266            user_id: None,
267        }
268    }
269
270    pub fn to_client_id(&self) -> String {
271        self.client_id
272            .clone()
273            .unwrap_or_else(|| ClientIds::from_local("unknown"))
274    }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct MwsMessage {
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub scope_id: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub from: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub target: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub sig: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub r#type: Option<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub payload: Option<String>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub error: Option<ErrorResponse>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub client_info: Option<MwsClientInfo>,
296}
297
298impl MwsMessage {
299    pub fn create(
300        scope_id: Option<String>,
301        target: String,
302        sig: String,
303        payload: String,
304        msg_type: String,
305    ) -> Self {
306        Self {
307            scope_id,
308            target: Some(target),
309            sig: Some(sig),
310            payload: Some(payload),
311            r#type: Some(msg_type),
312            from: None,
313            error: None,
314            client_info: None,
315        }
316    }
317
318    pub fn dummy() -> Self {
319        Self {
320            scope_id: None,
321            from: None,
322            target: None,
323            sig: None,
324            r#type: None,
325            payload: None,
326            error: None,
327            client_info: None,
328        }
329    }
330
331    pub fn server_response(
332        target: String,
333        sig: String,
334        payload: String,
335        client_info: MwsClientInfo,
336    ) -> Self {
337        Self {
338            scope_id: None,
339            from: None,
340            target: Some(target),
341            sig: Some(sig),
342            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
343            payload: Some(payload),
344            error: None,
345            client_info: Some(client_info),
346        }
347    }
348
349    pub fn server_response_error(
350        target: String,
351        sig: String,
352        error: ErrorResponse,
353        client_info: MwsClientInfo,
354    ) -> Self {
355        Self {
356            scope_id: None,
357            from: None,
358            target: Some(target),
359            sig: Some(sig),
360            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
361            payload: None,
362            error: Some(error),
363            client_info: Some(client_info),
364        }
365    }
366
367    pub fn unscoped_server_data(
368        target: String,
369        payload: String,
370        client_info: MwsClientInfo,
371    ) -> Self {
372        Self {
373            scope_id: None,
374            from: None,
375            target: Some(target),
376            sig: None,
377            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
378            payload: Some(payload),
379            error: None,
380            client_info: Some(client_info),
381        }
382    }
383
384    pub fn scoped_server_data(
385        scope_id: String,
386        target: String,
387        payload: String,
388        client_info: Option<MwsClientInfo>,
389    ) -> Self {
390        Self {
391            scope_id: Some(scope_id),
392            from: None,
393            target: Some(target),
394            sig: None,
395            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
396            payload: Some(payload),
397            error: None,
398            client_info,
399        }
400    }
401
402    pub fn hub_response(
403        target: String,
404        sig: String,
405        payload: String,
406        client_info: MwsClientInfo,
407    ) -> Self {
408        Self {
409            scope_id: None,
410            from: None,
411            target: Some(target),
412            sig: Some(sig),
413            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
414            payload: Some(payload),
415            error: None,
416            client_info: Some(client_info),
417        }
418    }
419
420    pub fn hub_response_error(
421        target: String,
422        sig: String,
423        error: ErrorResponse,
424        client_info: MwsClientInfo,
425    ) -> Self {
426        Self {
427            scope_id: None,
428            from: None,
429            target: Some(target),
430            sig: Some(sig),
431            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
432            payload: None,
433            error: Some(error),
434            client_info: Some(client_info),
435        }
436    }
437
438    pub fn hub_request(
439        scope_id: Option<String>,
440        target: String,
441        sig: String,
442        payload: String,
443    ) -> Self {
444        Self {
445            scope_id,
446            from: None,
447            target: Some(target),
448            sig: Some(sig),
449            r#type: Some(MwsMessageType::HUB_REQ.to_string()),
450            payload: Some(payload),
451            error: None,
452            client_info: None,
453        }
454    }
455
456    pub fn hub_data(
457        scope_id: Option<String>,
458        target: String,
459        sig: String,
460        payload: String,
461    ) -> Self {
462        Self {
463            scope_id,
464            from: None,
465            target: Some(target),
466            sig: Some(sig),
467            r#type: Some(MwsMessageType::HUB_DATA.to_string()),
468            payload: Some(payload),
469            error: None,
470            client_info: None,
471        }
472    }
473
474    pub fn set_from(&mut self, from: String) {
475        self.from = Some(from);
476    }
477
478    pub fn set_target(&mut self, target: String) {
479        self.target = Some(target);
480    }
481
482    pub fn set_sig(&mut self, sig: String) {
483        self.sig = Some(sig);
484    }
485
486    pub fn set_payload(&mut self, payload: String) {
487        self.payload = Some(payload);
488    }
489
490    pub fn set_type(&mut self, msg_type: String) {
491        self.r#type = Some(msg_type);
492    }
493
494    pub fn set_client_info(&mut self, client_info: MwsClientInfo) {
495        self.client_info = Some(client_info);
496    }
497}
498
499impl Default for MwsMessage {
500    fn default() -> Self {
501        Self::dummy()
502    }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct NodeAuthRequest {
508    pub hub_id: String,
509    pub token: String,
510    pub node_type: String,
511    #[serde(default)]
512    pub node_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, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(rename_all = "camelCase", deny_unknown_fields)]
954pub struct ScopeMembershipListRequest {
955    pub tenant_id: String,
956    pub user_id: String,
957}
958
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[serde(rename_all = "camelCase", deny_unknown_fields)]
961pub struct ScopeMembership {
962    pub tenant_id: String,
963    pub scope_id: String,
964    pub user_id: String,
965}
966
967/// A service-owned AppClient materialized from an authoritative external model.
968/// This boundary intentionally carries no acting user: authorization belongs to
969/// the service that owns the source model, while Core validates the projection.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(rename_all = "camelCase", deny_unknown_fields)]
972pub struct ServiceAppClientProjection {
973    pub tenant_id: String,
974    pub scope_id: String,
975    pub app_client_id: String,
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub user_id: Option<String>,
978    pub source: String,
979    pub device_type: String,
980    #[serde(default)]
981    pub scope_owned: bool,
982}
983
984#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
985#[serde(rename_all = "camelCase", deny_unknown_fields)]
986pub struct ServiceAppClientProjectionKey {
987    pub tenant_id: String,
988    pub scope_id: String,
989    pub app_client_id: String,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize)]
993pub enum ServiceCoreInput {
994    ClientAuth {
995        message: MwsMessage,
996        ws_id: String,
997        scope_id: String,
998    },
999    ClientRequest {
1000        target: String,
1001        payload: String,
1002        ws_id: String,
1003        tenant_id: String,
1004        scope_id: String,
1005        user_id: String,
1006        is_test: bool,
1007        #[serde(default, skip_serializing_if = "Option::is_none")]
1008        surface_id: Option<String>,
1009    },
1010    /// Invokes the canonical Conversation application boundary without a
1011    /// websocket/client transport identity.
1012    ConversationRequest {
1013        target: String,
1014        payload: String,
1015        tenant_id: String,
1016        scope_id: String,
1017        actor_user_id: String,
1018        surface_id: String,
1019        is_test: bool,
1020    },
1021    /// Authorizes a trusted headless actor before a non-Conversation operation
1022    /// such as binding a shared messaging surface.
1023    ScopeAuthorization {
1024        request: ScopeAuthorizationRequest,
1025    },
1026    /// Lists authoritative scope memberships for a trusted headless principal.
1027    ScopeMembershipList {
1028        request: ScopeMembershipListRequest,
1029    },
1030    /// Idempotently materializes a trusted service-owned AppClient.
1031    AppClientProjectionPut {
1032        projection: ServiceAppClientProjection,
1033    },
1034    /// Idempotently removes a trusted service-owned AppClient.
1035    AppClientProjectionDelete {
1036        key: ServiceAppClientProjectionKey,
1037    },
1038    NodeAuth {
1039        request: NodeAuthRequest,
1040    },
1041    HubConnectionProof {
1042        request: HubConnectionProofRequest,
1043    },
1044    NodeRequest {
1045        target: String,
1046        payload: String,
1047        tenant_id: String,
1048        scope_id: String,
1049        node_type: String,
1050        node_id: String,
1051    },
1052    BackendStatusChanged {
1053        tenant_id: String,
1054        scope_id: String,
1055        target: String,
1056        payload: String,
1057    },
1058    ClientResponse {
1059        message: MwsMessage,
1060    },
1061    NodeResponse {
1062        message: MwsMessage,
1063    },
1064    IssueLocalSessionToken {
1065        tenant_id: String,
1066        scope_id: String,
1067        user_id: String,
1068        app_client_id: String,
1069    },
1070    SetLocalAppClientFocus {
1071        ws_id: String,
1072        focused: bool,
1073    },
1074    RefreshLocalAppClient {
1075        ws_id: String,
1076    },
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1080pub enum ServiceCoreResponse {
1081    ClientAuth {
1082        session: Option<AuthenticatedSession>,
1083        response: MwsMessage,
1084    },
1085    ClientRequest {
1086        response_payload: Option<String>,
1087        client_info: MwsClientInfo,
1088    },
1089    ConversationRequest {
1090        response_payload: Option<String>,
1091    },
1092    ScopeAuthorization {
1093        authorized: bool,
1094    },
1095    ScopeMembershipList {
1096        memberships: Vec<ScopeMembership>,
1097    },
1098    AppClientProjection {
1099        applied: bool,
1100    },
1101    NodeAuth(NodeAuthResponse),
1102    HubConnectionProof(HubConnectionProofResponse),
1103    NodeRequest {
1104        response_payload: Option<String>,
1105    },
1106    LocalSessionToken(String),
1107    Ack {
1108        handled: bool,
1109    },
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113pub enum ServiceCoreEffect {
1114    Local {
1115        client_id: String,
1116        message: MwsMessage,
1117    },
1118    Bridge {
1119        tenant_id: String,
1120        scope_id: String,
1121        message: MwsMessage,
1122    },
1123    Node {
1124        connection_key: String,
1125        message: MwsMessage,
1126    },
1127    NodeDisconnect {
1128        connection_key: String,
1129    },
1130    Surface {
1131        tenant_id: String,
1132        scope_id: String,
1133        surface_id: String,
1134        target: String,
1135        payload: String,
1136    },
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1140pub struct ServiceCoreOutput {
1141    #[serde(skip_serializing_if = "Option::is_none")]
1142    pub response: Option<ServiceCoreResponse>,
1143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1144    pub effects: Vec<ServiceCoreEffect>,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1148#[serde(rename_all = "camelCase")]
1149pub struct UserSetting {
1150    #[serde(default)]
1151    pub script_mode: bool,
1152    #[serde(default)]
1153    pub debug_mode: bool,
1154    #[serde(default)]
1155    pub eng_account: bool,
1156}
1157
1158impl UserSetting {
1159    pub fn new() -> Self {
1160        Self {
1161            script_mode: false,
1162            debug_mode: false,
1163            eng_account: false,
1164        }
1165    }
1166}
1167
1168impl Default for UserSetting {
1169    fn default() -> Self {
1170        Self::new()
1171    }
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175#[serde(rename_all = "camelCase")]
1176pub struct UserInfo {
1177    #[serde(skip_serializing_if = "Option::is_none")]
1178    pub id: Option<String>,
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub name: Option<String>,
1181    #[serde(skip_serializing_if = "Option::is_none")]
1182    pub email: Option<String>,
1183    #[serde(skip_serializing_if = "Option::is_none")]
1184    pub setting: Option<UserSetting>,
1185    #[serde(default)]
1186    pub eng: bool,
1187}
1188
1189impl UserInfo {
1190    pub fn new(id: String) -> Self {
1191        Self {
1192            id: Some(id),
1193            name: None,
1194            email: None,
1195            setting: None,
1196            eng: false,
1197        }
1198    }
1199}
1200
1201#[derive(Debug, Clone, Serialize, Deserialize)]
1202#[serde(rename_all = "camelCase")]
1203pub struct ScopeMember {
1204    pub id: Option<String>,
1205    pub email: Option<String>,
1206    pub name: Option<String>,
1207    pub pending: bool,
1208    pub role: Option<String>,
1209}
1210
1211impl Default for ScopeMember {
1212    fn default() -> Self {
1213        Self {
1214            id: None,
1215            email: None,
1216            name: None,
1217            pending: false,
1218            role: None,
1219        }
1220    }
1221}
1222
1223#[derive(Debug, Clone, Serialize, Deserialize)]
1224#[serde(rename_all = "camelCase")]
1225pub struct ScopeInfo {
1226    pub name: Option<String>,
1227    pub id: Option<String>,
1228    pub pending: bool,
1229    pub members: Option<Vec<ScopeMember>>,
1230    pub execution_env: Option<String>,
1231    pub mode: Option<String>,
1232    pub connection_mode: Option<String>,
1233    pub agent_mode: Option<String>,
1234    pub default_active: bool,
1235    #[serde(default)]
1236    pub is_test: bool,
1237}
1238
1239impl Default for ScopeInfo {
1240    fn default() -> Self {
1241        Self {
1242            name: None,
1243            id: None,
1244            pending: false,
1245            members: None,
1246            execution_env: None,
1247            mode: None,
1248            connection_mode: None,
1249            agent_mode: None,
1250            default_active: false,
1251            is_test: false,
1252        }
1253    }
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize)]
1257#[serde(rename_all = "camelCase")]
1258pub struct AuthConfig {
1259    pub tenant_id: String,
1260    pub heartbeat_interval: i32,
1261    pub command_timeout: i32,
1262    pub user_info: UserInfo,
1263    pub scope: ScopeInfo,
1264    #[serde(skip_serializing_if = "Option::is_none")]
1265    pub hub_id: Option<String>,
1266    #[serde(skip_serializing_if = "Option::is_none")]
1267    pub jwt_token: Option<String>,
1268}
1269
1270impl AuthConfig {
1271    pub fn new(
1272        tenant_id: String,
1273        heartbeat_interval: i32,
1274        command_timeout: i32,
1275        user_info: UserInfo,
1276        scope: ScopeInfo,
1277    ) -> Self {
1278        Self {
1279            tenant_id,
1280            heartbeat_interval,
1281            command_timeout,
1282            user_info,
1283            scope,
1284            hub_id: None,
1285            jwt_token: None,
1286        }
1287    }
1288}
1289
1290impl Default for AuthConfig {
1291    fn default() -> Self {
1292        Self {
1293            tenant_id: String::new(),
1294            heartbeat_interval: 240,
1295            command_timeout: 10,
1296            user_info: UserInfo::new(String::new()),
1297            scope: ScopeInfo::default(),
1298            hub_id: None,
1299            jwt_token: None,
1300        }
1301    }
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1305#[serde(rename_all = "camelCase")]
1306pub struct AuthRequest {
1307    pub token: String,
1308    pub source: String,
1309    pub scope_id: String,
1310    pub device_id: String,
1311    pub client_source: String,
1312    #[serde(default)]
1313    pub tenant_id: Option<String>,
1314    #[serde(default)]
1315    pub hub_id: Option<String>,
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize)]
1319#[serde(rename_all = "camelCase")]
1320pub struct HubMdnsInstanceRecord {
1321    pub tenant_id: String,
1322    pub scope_id: String,
1323    pub hub_id: String,
1324    pub scope_name: String,
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::{
1330        hub_connection_proof_signing_payload, HubConnectionProofRequest, MwsMessage,
1331        MwsMessageType, NodeOnboardingStartRequest, HUB_CONNECTION_PROOF_PROTOCOL,
1332    };
1333
1334    #[test]
1335    fn hub_connection_proof_payload_is_unambiguous_and_nonce_bound() {
1336        let request = HubConnectionProofRequest {
1337            protocol: HUB_CONNECTION_PROOF_PROTOCOL.to_string(),
1338            hub_id: "hub-1".to_string(),
1339            tenant_id: "tenant-1".to_string(),
1340            scope_id: "scope-1".to_string(),
1341            nonce: "nonce-1".to_string(),
1342        };
1343        let payload = hub_connection_proof_signing_payload(&request);
1344        let mut changed = request.clone();
1345        changed.nonce = "nonce-2".to_string();
1346
1347        assert_ne!(payload, hub_connection_proof_signing_payload(&changed));
1348        assert!(payload.starts_with(&(HUB_CONNECTION_PROOF_PROTOCOL.len() as u64).to_be_bytes()));
1349    }
1350
1351    #[test]
1352    fn scoped_server_data_builds_scope_envelope() {
1353        let message = MwsMessage::scoped_server_data(
1354            "scope-1".to_string(),
1355            "/dialog".to_string(),
1356            "{}".to_string(),
1357            None,
1358        );
1359
1360        assert_eq!(message.scope_id.as_deref(), Some("scope-1"));
1361        assert_eq!(message.target.as_deref(), Some("/dialog"));
1362        assert_eq!(message.r#type.as_deref(), Some(MwsMessageType::SERVER_DATA));
1363        assert_eq!(message.payload.as_deref(), Some("{}"));
1364        assert!(message.client_info.is_none());
1365    }
1366
1367    #[test]
1368    fn node_onboarding_start_contract_uses_camel_case_and_rejects_unknown_fields() {
1369        let request = NodeOnboardingStartRequest {
1370            node_type: "matter".to_string(),
1371            candidate_host_id: "host-1".to_string(),
1372            candidate_fingerprint: "sha256:abc".to_string(),
1373            idempotency_key: "attempt-1".to_string(),
1374        };
1375
1376        let json = serde_json::to_value(&request).expect("serialize start request");
1377        assert_eq!(json["candidateHostId"], "host-1");
1378        assert_eq!(json["idempotencyKey"], "attempt-1");
1379
1380        let invalid = serde_json::json!({
1381            "nodeType": "matter",
1382            "candidateHostId": "host-1",
1383            "candidateFingerprint": "sha256:abc",
1384            "idempotencyKey": "attempt-1",
1385            "nonce": "caller-must-not-supply-this"
1386        });
1387        assert!(serde_json::from_value::<NodeOnboardingStartRequest>(invalid).is_err());
1388    }
1389}