Skip to main content

core_api/
lib.rs

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