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    #[serde(skip_serializing_if = "Option::is_none")]
303    pub error: Option<ErrorResponse>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub client_info: Option<MwsClientInfo>,
306}
307
308impl MwsMessage {
309    pub fn create(
310        scope_id: Option<String>,
311        target: String,
312        sig: String,
313        payload: String,
314        msg_type: String,
315    ) -> Self {
316        Self {
317            scope_id,
318            target: Some(target),
319            sig: Some(sig),
320            payload: Some(payload),
321            r#type: Some(msg_type),
322            from: None,
323            error: None,
324            client_info: None,
325        }
326    }
327
328    pub fn dummy() -> Self {
329        Self {
330            scope_id: None,
331            from: None,
332            target: None,
333            sig: None,
334            r#type: None,
335            payload: None,
336            error: None,
337            client_info: None,
338        }
339    }
340
341    pub fn server_response(
342        target: String,
343        sig: String,
344        payload: String,
345        client_info: MwsClientInfo,
346    ) -> Self {
347        Self {
348            scope_id: None,
349            from: None,
350            target: Some(target),
351            sig: Some(sig),
352            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
353            payload: Some(payload),
354            error: None,
355            client_info: Some(client_info),
356        }
357    }
358
359    pub fn server_response_error(
360        target: String,
361        sig: String,
362        error: ErrorResponse,
363        client_info: MwsClientInfo,
364    ) -> Self {
365        Self {
366            scope_id: None,
367            from: None,
368            target: Some(target),
369            sig: Some(sig),
370            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
371            payload: None,
372            error: Some(error),
373            client_info: Some(client_info),
374        }
375    }
376
377    pub fn unscoped_server_data(
378        target: String,
379        payload: String,
380        client_info: MwsClientInfo,
381    ) -> Self {
382        Self {
383            scope_id: None,
384            from: None,
385            target: Some(target),
386            sig: None,
387            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
388            payload: Some(payload),
389            error: None,
390            client_info: Some(client_info),
391        }
392    }
393
394    pub fn scoped_server_data(
395        scope_id: String,
396        target: String,
397        payload: String,
398        client_info: Option<MwsClientInfo>,
399    ) -> Self {
400        Self {
401            scope_id: Some(scope_id),
402            from: None,
403            target: Some(target),
404            sig: None,
405            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
406            payload: Some(payload),
407            error: None,
408            client_info,
409        }
410    }
411
412    pub fn hub_response(
413        target: String,
414        sig: String,
415        payload: String,
416        client_info: MwsClientInfo,
417    ) -> Self {
418        Self {
419            scope_id: None,
420            from: None,
421            target: Some(target),
422            sig: Some(sig),
423            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
424            payload: Some(payload),
425            error: None,
426            client_info: Some(client_info),
427        }
428    }
429
430    pub fn hub_response_error(
431        target: String,
432        sig: String,
433        error: ErrorResponse,
434        client_info: MwsClientInfo,
435    ) -> Self {
436        Self {
437            scope_id: None,
438            from: None,
439            target: Some(target),
440            sig: Some(sig),
441            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
442            payload: None,
443            error: Some(error),
444            client_info: Some(client_info),
445        }
446    }
447
448    pub fn hub_request(
449        scope_id: Option<String>,
450        target: String,
451        sig: String,
452        payload: String,
453    ) -> Self {
454        Self {
455            scope_id,
456            from: None,
457            target: Some(target),
458            sig: Some(sig),
459            r#type: Some(MwsMessageType::HUB_REQ.to_string()),
460            payload: Some(payload),
461            error: None,
462            client_info: None,
463        }
464    }
465
466    pub fn hub_data(
467        scope_id: Option<String>,
468        target: String,
469        sig: String,
470        payload: String,
471    ) -> Self {
472        Self {
473            scope_id,
474            from: None,
475            target: Some(target),
476            sig: Some(sig),
477            r#type: Some(MwsMessageType::HUB_DATA.to_string()),
478            payload: Some(payload),
479            error: None,
480            client_info: None,
481        }
482    }
483
484    pub fn set_from(&mut self, from: String) {
485        self.from = Some(from);
486    }
487
488    pub fn set_target(&mut self, target: String) {
489        self.target = Some(target);
490    }
491
492    pub fn set_sig(&mut self, sig: String) {
493        self.sig = Some(sig);
494    }
495
496    pub fn set_payload(&mut self, payload: String) {
497        self.payload = Some(payload);
498    }
499
500    pub fn set_type(&mut self, msg_type: String) {
501        self.r#type = Some(msg_type);
502    }
503
504    pub fn set_client_info(&mut self, client_info: MwsClientInfo) {
505        self.client_info = Some(client_info);
506    }
507}
508
509impl Default for MwsMessage {
510    fn default() -> Self {
511        Self::dummy()
512    }
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517pub struct NodeAuthRequest {
518    /// Revision of this Node type's Core-facing contract, independent of package versions.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub core_protocol_version: Option<u32>,
521    pub hub_id: String,
522    pub token: String,
523    pub node_type: String,
524    #[serde(default)]
525    pub node_id: String,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub scope_id: Option<String>,
528    pub host_id: String,
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub host_name: Option<String>,
531    pub fingerprint: String,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
535#[serde(rename_all = "camelCase")]
536pub struct NodeAuthResponse {
537    /// Echoed only after the actual Core accepts the Node contract revision.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub core_protocol_version: Option<u32>,
540    pub ok: bool,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub session_id: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub hub_id: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub tenant_id: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub scope_id: Option<String>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub error: Option<String>,
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub error_code: Option<String>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub recovery_action: Option<String>,
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub node_id: Option<String>,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct NodeTokenIssueRequest {
562    pub transaction_id: String,
563    pub node_type: String,
564    pub candidate_host_id: String,
565    pub candidate_fingerprint: String,
566    pub challenge: NodeChallengeEvidence,
567}
568
569#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
570#[serde(rename_all = "camelCase")]
571pub enum NodeInstancePolicy {
572    Multiple,
573    Singleton,
574    Shared,
575}
576
577impl NodeInstancePolicy {
578    pub fn as_str(self) -> &'static str {
579        match self {
580            Self::Multiple => "multiple",
581            Self::Singleton => "singleton",
582            Self::Shared => "shared",
583        }
584    }
585}
586
587impl Default for NodeInstancePolicy {
588    fn default() -> Self {
589        Self::Multiple
590    }
591}
592
593impl std::fmt::Display for NodeInstancePolicy {
594    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
595        f.write_str(self.as_str())
596    }
597}
598
599impl FromStr for NodeInstancePolicy {
600    type Err = String;
601
602    fn from_str(value: &str) -> Result<Self, Self::Err> {
603        match value.trim() {
604            "multiple" | "Multiple" => Ok(Self::Multiple),
605            "singleton" | "Singleton" => Ok(Self::Singleton),
606            "shared" | "Shared" => Ok(Self::Shared),
607            other => Err(format!("unsupported node instance policy: {other}")),
608        }
609    }
610}
611
612#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
613#[serde(rename_all = "camelCase")]
614pub enum NodeBindingStatus {
615    Pending,
616    Active,
617    Revoked,
618    Expired,
619    Failed,
620}
621
622impl NodeBindingStatus {
623    pub fn as_str(self) -> &'static str {
624        match self {
625            Self::Pending => "pending",
626            Self::Active => "active",
627            Self::Revoked => "revoked",
628            Self::Expired => "expired",
629            Self::Failed => "failed",
630        }
631    }
632}
633
634impl Default for NodeBindingStatus {
635    fn default() -> Self {
636        Self::Active
637    }
638}
639
640impl std::fmt::Display for NodeBindingStatus {
641    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642        f.write_str(self.as_str())
643    }
644}
645
646impl FromStr for NodeBindingStatus {
647    type Err = String;
648
649    fn from_str(value: &str) -> Result<Self, Self::Err> {
650        match value.trim() {
651            "pending" | "Pending" => Ok(Self::Pending),
652            "active" | "Active" => Ok(Self::Active),
653            "revoked" | "Revoked" => Ok(Self::Revoked),
654            "expired" | "Expired" => Ok(Self::Expired),
655            "failed" | "Failed" => Ok(Self::Failed),
656            other => Err(format!("unsupported node binding status: {other}")),
657        }
658    }
659}
660
661pub const NODE_ONBOARDING_CHALLENGE_PROTOCOL: &str = "meow.node.onboarding.challenge";
662pub const NODE_ONBOARDING_CHALLENGE_AUDIENCE: &str = "meow-core:node-onboarding";
663pub const NODE_ONBOARDING_CHALLENGE_ALGORITHM: &str = "Ed25519";
664pub const NODE_ONBOARDING_TOKEN_ISSUER_PREFIX: &str = "meow-core:hub:";
665pub const NODE_ONBOARDING_TOKEN_AUDIENCE: &str = "meow-node:onboarding";
666pub const NODE_ONBOARDING_ES256_ALGORITHM: &str = "ES256";
667pub const NODE_ONBOARDING_TRANSACTION_TTL_SECONDS: u64 = 5 * 60;
668pub const NODE_ONBOARDING_START_TARGET: &str = "/identity/node/onboarding/start";
669pub const NODE_TOKEN_ISSUE_TARGET: &str = "/identity/node/issue";
670pub const NODE_TOKEN_REVOKE_TARGET: &str = "/identity/node/revoke";
671pub const NODE_TOKEN_LIST_TARGET: &str = "/identity/node/list";
672pub const HUB_CONNECTION_PROOF_TARGET: &str = "/identity/hub/prove";
673pub const HUB_CONNECTION_PROOF_PROTOCOL: &str = "meow.hub.connection.proof.v1";
674
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
676#[serde(rename_all = "camelCase", deny_unknown_fields)]
677pub struct NodeOnboardingStartRequest {
678    pub node_type: String,
679    pub candidate_host_id: String,
680    pub candidate_fingerprint: String,
681    pub idempotency_key: String,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
685#[serde(rename_all = "camelCase", deny_unknown_fields)]
686pub struct NodeOnboardingStartResponse {
687    pub transaction_id: String,
688    pub nonce: String,
689    pub expires_at: i64,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
693#[serde(rename_all = "camelCase")]
694pub struct HubConnectionProofRequest {
695    pub protocol: String,
696    pub hub_id: String,
697    pub tenant_id: String,
698    pub scope_id: String,
699    pub nonce: String,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
703#[serde(rename_all = "camelCase")]
704pub struct HubConnectionProofResponse {
705    pub protocol: String,
706    pub hub_id: String,
707    pub tenant_id: String,
708    pub scope_id: String,
709    pub nonce: String,
710    pub key_id: String,
711    pub signature: String,
712}
713
714/// Canonical, domain-separated bytes signed by the Hub for one physical WS connection.
715/// Length prefixes keep the encoding unambiguous without relying on JSON object ordering.
716pub fn hub_connection_proof_signing_payload(request: &HubConnectionProofRequest) -> Vec<u8> {
717    let fields = [
718        request.protocol.as_str(),
719        request.hub_id.as_str(),
720        request.tenant_id.as_str(),
721        request.scope_id.as_str(),
722        request.nonce.as_str(),
723    ];
724    let mut payload = Vec::new();
725    for field in fields {
726        payload.extend_from_slice(&(field.len() as u64).to_be_bytes());
727        payload.extend_from_slice(field.as_bytes());
728    }
729    payload
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize)]
733#[serde(rename_all = "camelCase")]
734pub struct NodeChallengeEvidence {
735    pub protocol: String,
736    pub algorithm: String,
737    pub payload: String,
738    pub signature: String,
739    pub public_key: String,
740    pub fingerprint: String,
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize)]
744#[serde(rename_all = "camelCase")]
745pub struct NodeChallengePayload {
746    pub protocol: String,
747    pub aud: String,
748    pub nonce: String,
749    pub scope_id: String,
750    pub node_type: String,
751    pub host_id: String,
752    pub fingerprint: String,
753    pub service_instance_id: String,
754    pub instance_policy: NodeInstancePolicy,
755    pub instance_slot: String,
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
759#[serde(rename_all = "camelCase")]
760pub struct NodeTokenIssueResponse {
761    pub token: String,
762    pub node_id: String,
763    pub expires_in: i64,
764    pub hub_id: String,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768#[serde(rename_all = "camelCase")]
769pub struct NodeTokenRevokeRequest {
770    pub node_type: String,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub node_id: Option<String>,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(rename_all = "camelCase")]
777pub struct NodeTokenRevokeResponse {
778    pub success: bool,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize)]
782#[serde(rename_all = "camelCase")]
783pub struct NodeTokenListItem {
784    pub node_id: String,
785    pub node_type: String,
786    pub hub_id: String,
787    pub host_id: String,
788    pub fingerprint: String,
789    pub service_instance_id: String,
790    pub instance_policy: NodeInstancePolicy,
791    pub instance_slot: String,
792    pub status: NodeBindingStatus,
793    pub issued_at: i64,
794    pub expires_at: i64,
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(rename_all = "camelCase")]
799pub struct NodeTokenListResponse {
800    #[serde(default)]
801    pub nodes: Vec<NodeTokenListItem>,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize)]
805#[serde(rename_all = "camelCase")]
806pub struct NodeInstanceListItem {
807    pub node_id: String,
808    pub node_type: String,
809    pub hub_id: String,
810    pub host_id: String,
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub host_name: Option<String>,
813    pub fingerprint: String,
814    pub service_instance_id: String,
815    pub instance_policy: NodeInstancePolicy,
816    pub instance_slot: String,
817    pub binding_status: NodeBindingStatus,
818    pub issued_at: i64,
819    pub expires_at: i64,
820    pub connected: bool,
821    pub runtime_status: String,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize)]
825#[serde(rename_all = "camelCase")]
826pub struct NodeInstanceListResponse {
827    #[serde(default)]
828    pub instances: Vec<NodeInstanceListItem>,
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
832#[serde(rename_all = "camelCase")]
833pub struct AuthenticatedSession {
834    pub tenant_id: String,
835    pub scope_id: String,
836    pub user_id: String,
837    pub is_test: bool,
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(rename_all = "snake_case")]
842pub enum ScopeAccessRequirement {
843    Member,
844    Owner,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase", deny_unknown_fields)]
849pub struct ScopeAuthorizationRequest {
850    pub tenant_id: String,
851    pub scope_id: String,
852    pub user_id: String,
853    pub requirement: ScopeAccessRequirement,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(rename_all = "camelCase", deny_unknown_fields)]
858pub struct ScopeMembershipListRequest {
859    pub tenant_id: String,
860    pub user_id: String,
861}
862
863#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "camelCase", deny_unknown_fields)]
865pub struct ScopeMembership {
866    pub tenant_id: String,
867    pub scope_id: String,
868    pub user_id: String,
869}
870
871/// A service-owned AppClient materialized from an authoritative external model.
872/// This boundary intentionally carries no acting user: authorization belongs to
873/// the service that owns the source model, while Core validates the projection.
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(rename_all = "camelCase", deny_unknown_fields)]
876pub struct ServiceAppClientProjection {
877    pub tenant_id: String,
878    pub scope_id: String,
879    pub app_client_id: String,
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub user_id: Option<String>,
882    pub source: String,
883    pub device_type: String,
884    #[serde(default)]
885    pub scope_owned: bool,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889#[serde(rename_all = "camelCase", deny_unknown_fields)]
890pub struct ServiceAppClientProjectionKey {
891    pub tenant_id: String,
892    pub scope_id: String,
893    pub app_client_id: String,
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize)]
897pub enum ServiceCoreInput {
898    ClientAuth {
899        message: MwsMessage,
900        ws_id: String,
901        scope_id: String,
902    },
903    ClientRequest {
904        target: String,
905        payload: String,
906        ws_id: String,
907        tenant_id: String,
908        scope_id: String,
909        user_id: String,
910        is_test: bool,
911        #[serde(default, skip_serializing_if = "Option::is_none")]
912        surface_id: Option<String>,
913    },
914    /// Invokes the canonical Conversation application boundary without a
915    /// websocket/client transport identity.
916    ConversationRequest {
917        target: String,
918        payload: String,
919        tenant_id: String,
920        scope_id: String,
921        actor_user_id: String,
922        surface_id: String,
923        is_test: bool,
924    },
925    /// Authorizes a trusted headless actor before a non-Conversation operation
926    /// such as binding a shared messaging surface.
927    ScopeAuthorization {
928        request: ScopeAuthorizationRequest,
929    },
930    /// Lists authoritative scope memberships for a trusted headless principal.
931    ScopeMembershipList {
932        request: ScopeMembershipListRequest,
933    },
934    /// Idempotently materializes a trusted service-owned AppClient.
935    AppClientProjectionPut {
936        projection: ServiceAppClientProjection,
937    },
938    /// Idempotently removes a trusted service-owned AppClient.
939    AppClientProjectionDelete {
940        key: ServiceAppClientProjectionKey,
941    },
942    NodeAuth {
943        request: NodeAuthRequest,
944    },
945    HubConnectionProof {
946        request: HubConnectionProofRequest,
947    },
948    NodeRequest {
949        target: String,
950        payload: String,
951        tenant_id: String,
952        scope_id: String,
953        node_type: String,
954        node_id: String,
955    },
956    /// Reports status from an already authenticated Node transport. Core uses
957    /// `connection_key` to bind the untrusted payload to the authoritative
958    /// Node session before accepting it into the Hub-owned status projection.
959    NodeStatusObserved {
960        connection_key: String,
961        status: Box<node::status::StatusPayload>,
962    },
963    ClientResponse {
964        message: MwsMessage,
965    },
966    NodeResponse {
967        message: MwsMessage,
968    },
969    IssueLocalSessionToken {
970        tenant_id: String,
971        scope_id: String,
972        user_id: String,
973        app_client_id: String,
974    },
975    SetLocalAppClientFocus {
976        ws_id: String,
977        focused: bool,
978    },
979    RefreshLocalAppClient {
980        ws_id: String,
981    },
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize)]
985pub enum ServiceCoreResponse {
986    ClientAuth {
987        session: Option<AuthenticatedSession>,
988        response: MwsMessage,
989    },
990    ClientRequest {
991        response_payload: Option<String>,
992        client_info: MwsClientInfo,
993    },
994    ConversationRequest {
995        response_payload: Option<String>,
996    },
997    ScopeAuthorization {
998        authorized: bool,
999    },
1000    ScopeMembershipList {
1001        memberships: Vec<ScopeMembership>,
1002    },
1003    AppClientProjection {
1004        applied: bool,
1005    },
1006    NodeAuth(NodeAuthResponse),
1007    HubConnectionProof(HubConnectionProofResponse),
1008    NodeRequest {
1009        response_payload: Option<String>,
1010    },
1011    LocalSessionToken(String),
1012    Ack {
1013        handled: bool,
1014    },
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1018#[serde(deny_unknown_fields)]
1019pub struct ServiceCoreOutput {
1020    #[serde(skip_serializing_if = "Option::is_none")]
1021    pub response: Option<ServiceCoreResponse>,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize)]
1025#[serde(rename_all = "camelCase")]
1026pub struct UserSetting {
1027    #[serde(default)]
1028    pub script_mode: bool,
1029    #[serde(default)]
1030    pub debug_mode: bool,
1031    #[serde(default)]
1032    pub eng_account: bool,
1033}
1034
1035impl UserSetting {
1036    pub fn new() -> Self {
1037        Self {
1038            script_mode: false,
1039            debug_mode: false,
1040            eng_account: false,
1041        }
1042    }
1043}
1044
1045impl Default for UserSetting {
1046    fn default() -> Self {
1047        Self::new()
1048    }
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1052#[serde(rename_all = "camelCase")]
1053pub struct UserInfo {
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub id: Option<String>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub name: Option<String>,
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub email: Option<String>,
1060    #[serde(skip_serializing_if = "Option::is_none")]
1061    pub setting: Option<UserSetting>,
1062    #[serde(default)]
1063    pub eng: bool,
1064}
1065
1066impl UserInfo {
1067    pub fn new(id: String) -> Self {
1068        Self {
1069            id: Some(id),
1070            name: None,
1071            email: None,
1072            setting: None,
1073            eng: false,
1074        }
1075    }
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize)]
1079#[serde(rename_all = "camelCase")]
1080pub struct ScopeMember {
1081    pub id: Option<String>,
1082    pub email: Option<String>,
1083    pub name: Option<String>,
1084    pub pending: bool,
1085    pub role: Option<String>,
1086}
1087
1088impl Default for ScopeMember {
1089    fn default() -> Self {
1090        Self {
1091            id: None,
1092            email: None,
1093            name: None,
1094            pending: false,
1095            role: None,
1096        }
1097    }
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize)]
1101#[serde(rename_all = "camelCase")]
1102pub struct ScopeInfo {
1103    pub name: Option<String>,
1104    pub id: Option<String>,
1105    pub pending: bool,
1106    pub members: Option<Vec<ScopeMember>>,
1107    pub execution_env: Option<String>,
1108    pub mode: Option<String>,
1109    pub connection_mode: Option<String>,
1110    pub agent_mode: Option<String>,
1111    pub default_active: bool,
1112    #[serde(default)]
1113    pub is_test: bool,
1114}
1115
1116impl Default for ScopeInfo {
1117    fn default() -> Self {
1118        Self {
1119            name: None,
1120            id: None,
1121            pending: false,
1122            members: None,
1123            execution_env: None,
1124            mode: None,
1125            connection_mode: None,
1126            agent_mode: None,
1127            default_active: false,
1128            is_test: false,
1129        }
1130    }
1131}
1132
1133#[derive(Debug, Clone, Serialize, Deserialize)]
1134#[serde(rename_all = "camelCase")]
1135pub struct AuthConfig {
1136    pub tenant_id: String,
1137    pub command_timeout: i32,
1138    pub user_info: UserInfo,
1139    pub scope: ScopeInfo,
1140    #[serde(skip_serializing_if = "Option::is_none")]
1141    pub hub_id: Option<String>,
1142    #[serde(skip_serializing_if = "Option::is_none")]
1143    pub jwt_token: Option<String>,
1144}
1145
1146impl AuthConfig {
1147    pub fn new(
1148        tenant_id: String,
1149        command_timeout: i32,
1150        user_info: UserInfo,
1151        scope: ScopeInfo,
1152    ) -> Self {
1153        Self {
1154            tenant_id,
1155            command_timeout,
1156            user_info,
1157            scope,
1158            hub_id: None,
1159            jwt_token: None,
1160        }
1161    }
1162}
1163
1164impl Default for AuthConfig {
1165    fn default() -> Self {
1166        Self {
1167            tenant_id: String::new(),
1168            command_timeout: 10,
1169            user_info: UserInfo::new(String::new()),
1170            scope: ScopeInfo::default(),
1171            hub_id: None,
1172            jwt_token: None,
1173        }
1174    }
1175}
1176
1177#[derive(Debug, Clone, Serialize, Deserialize)]
1178#[serde(rename_all = "camelCase")]
1179pub struct AuthRequest {
1180    pub token: String,
1181    pub source: String,
1182    pub scope_id: String,
1183    pub device_id: String,
1184    pub client_source: String,
1185    #[serde(default)]
1186    pub tenant_id: Option<String>,
1187    #[serde(default)]
1188    pub hub_id: Option<String>,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[serde(rename_all = "camelCase")]
1193pub struct HubMdnsInstanceRecord {
1194    pub tenant_id: String,
1195    pub scope_id: String,
1196    pub hub_id: String,
1197    pub scope_name: String,
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::{
1203        hub_connection_proof_signing_payload, HubConnectionProofRequest, MwsMessage,
1204        MwsMessageType, NodeOnboardingStartRequest, HUB_CONNECTION_PROOF_PROTOCOL,
1205    };
1206
1207    #[test]
1208    fn hub_connection_proof_payload_is_unambiguous_and_nonce_bound() {
1209        let request = HubConnectionProofRequest {
1210            protocol: HUB_CONNECTION_PROOF_PROTOCOL.to_string(),
1211            hub_id: "hub-1".to_string(),
1212            tenant_id: "tenant-1".to_string(),
1213            scope_id: "scope-1".to_string(),
1214            nonce: "nonce-1".to_string(),
1215        };
1216        let payload = hub_connection_proof_signing_payload(&request);
1217        let mut changed = request.clone();
1218        changed.nonce = "nonce-2".to_string();
1219
1220        assert_ne!(payload, hub_connection_proof_signing_payload(&changed));
1221        assert!(payload.starts_with(&(HUB_CONNECTION_PROOF_PROTOCOL.len() as u64).to_be_bytes()));
1222    }
1223
1224    #[test]
1225    fn scoped_server_data_builds_scope_envelope() {
1226        let message = MwsMessage::scoped_server_data(
1227            "scope-1".to_string(),
1228            "/dialog".to_string(),
1229            "{}".to_string(),
1230            None,
1231        );
1232
1233        assert_eq!(message.scope_id.as_deref(), Some("scope-1"));
1234        assert_eq!(message.target.as_deref(), Some("/dialog"));
1235        assert_eq!(message.r#type.as_deref(), Some(MwsMessageType::SERVER_DATA));
1236        assert_eq!(message.payload.as_deref(), Some("{}"));
1237        assert!(message.client_info.is_none());
1238    }
1239
1240    #[test]
1241    fn node_onboarding_start_contract_uses_camel_case_and_rejects_unknown_fields() {
1242        let request = NodeOnboardingStartRequest {
1243            node_type: "matter".to_string(),
1244            candidate_host_id: "host-1".to_string(),
1245            candidate_fingerprint: "sha256:abc".to_string(),
1246            idempotency_key: "attempt-1".to_string(),
1247        };
1248
1249        let json = serde_json::to_value(&request).expect("serialize start request");
1250        assert_eq!(json["candidateHostId"], "host-1");
1251        assert_eq!(json["idempotencyKey"], "attempt-1");
1252
1253        let invalid = serde_json::json!({
1254            "nodeType": "matter",
1255            "candidateHostId": "host-1",
1256            "candidateFingerprint": "sha256:abc",
1257            "idempotencyKey": "attempt-1",
1258            "nonce": "caller-must-not-supply-this"
1259        });
1260        assert!(serde_json::from_value::<NodeOnboardingStartRequest>(invalid).is_err());
1261    }
1262}