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 interaction_flow;
6pub mod llm;
7pub mod messaging;
8pub mod node;
9mod node_service;
10mod recipient;
11mod storage;
12
13pub use delivery::*;
14pub use external_rpc::*;
15pub use node_service::*;
16pub use recipient::*;
17pub use storage::*;
18
19use serde::{Deserialize, Serialize};
20use std::str::FromStr;
21
22/// Shared MWS transport limits. Both websocket peers must apply these values so
23/// an envelope accepted by one side is never rejected solely due to asymmetric
24/// transport configuration.
25pub const MWS_MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
26pub const MWS_MAX_FRAME_SIZE: usize = 4 * 1024 * 1024;
27pub const MWS_MAX_WRITE_BUFFER_SIZE: usize = 32 * 1024 * 1024;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub enum ExceptionCode {
31    Unsupported,
32    NullData,
33    ErrorSubscribe,
34    ErrorUnsubscribe,
35    ErrorProcessDataReport,
36    ErrorProcessDataPoll,
37    ErrorLock,
38    ErrorUnlock,
39    FunctionNoImpl,
40    Unreachable,
41    Unauthenticated,
42    Unauthorized,
43    PreconditionFail,
44    SessionExpired,
45    Internal,
46    Unknown,
47    NotFound,
48    AlreadyExists,
49    BadRequest,
50    InvalidWidgetDefinition,
51    DeadlineExceeded,
52    TemporaryUnavailable,
53    ResourceLocked,
54    WsConnectionLost,
55    BillAutomationExceed,
56    BillTokenExceed,
57    MissingContext,
58}
59
60impl ExceptionCode {
61    pub fn from_str(s: &str) -> Self {
62        match s {
63            "UNSUPPORTED" => Self::Unsupported,
64            "NULL_DATA" => Self::NullData,
65            "ERROR_SUBSCRIBE" => Self::ErrorSubscribe,
66            "ERROR_UNSUBSCRIBE" => Self::ErrorUnsubscribe,
67            "ERROR_PROCESS_DATA_REPORT" => Self::ErrorProcessDataReport,
68            "ERROR_PROCESS_DATA_POLL" => Self::ErrorProcessDataPoll,
69            "ERROR_LOCK" => Self::ErrorLock,
70            "ERROR_UNLOCK" => Self::ErrorUnlock,
71            "FUNCTION_NO_IMPL" => Self::FunctionNoImpl,
72            "UNREACHABLE" => Self::Unreachable,
73            "UNAUTHENTICATED" => Self::Unauthenticated,
74            "UNAUTHORIZED" => Self::Unauthorized,
75            "PRECONDITION_FAIL" => Self::PreconditionFail,
76            "SESSION_EXPIRED" => Self::SessionExpired,
77            "INTERNAL" => Self::Internal,
78            "NOT_FOUND" => Self::NotFound,
79            "ALREADY_EXISTS" => Self::AlreadyExists,
80            "BAD_REQUEST" => Self::BadRequest,
81            "INVALID_WIDGET_DEFINITION" => Self::InvalidWidgetDefinition,
82            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
83            "TEMPORARY_UNAVAILABLE" => Self::TemporaryUnavailable,
84            "RESOURCE_LOCKED" => Self::ResourceLocked,
85            "WS_CONNECTION_LOST" => Self::WsConnectionLost,
86            "BILL_AUTOMATION_EXCEED" => Self::BillAutomationExceed,
87            "BILL_TOKEN_EXCEED" => Self::BillTokenExceed,
88            "MISSING_CONTEXT" => Self::MissingContext,
89            _ => Self::Unknown,
90        }
91    }
92
93    pub fn as_str(&self) -> &'static str {
94        match self {
95            Self::Unsupported => "UNSUPPORTED",
96            Self::NullData => "NULL_DATA",
97            Self::ErrorSubscribe => "ERROR_SUBSCRIBE",
98            Self::ErrorUnsubscribe => "ERROR_UNSUBSCRIBE",
99            Self::ErrorProcessDataReport => "ERROR_PROCESS_DATA_REPORT",
100            Self::ErrorProcessDataPoll => "ERROR_PROCESS_DATA_POLL",
101            Self::ErrorLock => "ERROR_LOCK",
102            Self::ErrorUnlock => "ERROR_UNLOCK",
103            Self::FunctionNoImpl => "FUNCTION_NO_IMPL",
104            Self::Unreachable => "UNREACHABLE",
105            Self::Unauthenticated => "UNAUTHENTICATED",
106            Self::Unauthorized => "UNAUTHORIZED",
107            Self::PreconditionFail => "PRECONDITION_FAIL",
108            Self::SessionExpired => "SESSION_EXPIRED",
109            Self::Internal => "INTERNAL",
110            Self::Unknown => "UNKNOWN",
111            Self::NotFound => "NOT_FOUND",
112            Self::AlreadyExists => "ALREADY_EXISTS",
113            Self::BadRequest => "BAD_REQUEST",
114            Self::InvalidWidgetDefinition => "INVALID_WIDGET_DEFINITION",
115            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
116            Self::TemporaryUnavailable => "TEMPORARY_UNAVAILABLE",
117            Self::ResourceLocked => "RESOURCE_LOCKED",
118            Self::WsConnectionLost => "WS_CONNECTION_LOST",
119            Self::BillAutomationExceed => "BILL_AUTOMATION_EXCEED",
120            Self::BillTokenExceed => "BILL_TOKEN_EXCEED",
121            Self::MissingContext => "MISSING_CONTEXT",
122        }
123    }
124}
125
126impl From<ExceptionCode> for String {
127    fn from(value: ExceptionCode) -> Self {
128        value.as_str().to_string()
129    }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
133pub struct ErrorResponse {
134    pub error: String,
135    pub message: String,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub details: Option<serde_json::Value>,
138}
139
140impl ErrorResponse {
141    pub fn new(error_code: impl Into<String>, message: impl Into<String>) -> Self {
142        Self {
143            error: error_code.into(),
144            message: message.into(),
145            details: None,
146        }
147    }
148
149    pub fn with_details(mut self, details: serde_json::Value) -> Self {
150        self.details = Some(details);
151        self
152    }
153}
154
155pub struct ClientIds;
156
157impl ClientIds {
158    pub fn from_cloud(peer_id: &str, ws_id: &str) -> String {
159        format!("C:{}:{}", peer_id, ws_id)
160    }
161
162    pub fn from_local(ws_id: &str) -> String {
163        format!("L:{}", ws_id)
164    }
165
166    pub fn from_telegram(bot_id: &str, chat_id: i64) -> String {
167        format!("M:telegram:{}:{}", bot_id, chat_id)
168    }
169
170    pub fn is_cloud(client_id: &str) -> bool {
171        client_id.starts_with("C:")
172    }
173
174    pub fn is_local(client_id: &str) -> bool {
175        client_id.starts_with("L:")
176    }
177
178    pub fn is_telegram(client_id: &str) -> bool {
179        client_id.starts_with("M:telegram:")
180    }
181
182    pub fn is_messaging(client_id: &str) -> bool {
183        client_id.starts_with("M:")
184    }
185
186    pub fn to_telegram_bot_id(client_id: &str) -> Option<i64> {
187        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
188        parts.get(2)?.parse::<i64>().ok()
189    }
190
191    pub fn to_telegram_chat_id(client_id: &str) -> Option<i64> {
192        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
193        parts.get(3)?.parse::<i64>().ok()
194    }
195
196    pub fn to_peer_id(client_id: &str) -> Option<String> {
197        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
198        parts.get(1).map(|s| s.to_string())
199    }
200
201    pub fn to_device_id(client_id: &str) -> Option<String> {
202        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
203        parts.get(1).map(|s| s.to_string())
204    }
205}
206
207pub struct MwsMessageType;
208
209impl MwsMessageType {
210    pub const HUB_REQ: &'static str = "hrq";
211    pub const HUB_RESP: &'static str = "hrp";
212    pub const HUB_DATA: &'static str = "hd";
213    pub const NODE_REQ: &'static str = "nrq";
214    pub const NODE_RESP: &'static str = "nrp";
215    pub const NODE_DATA: &'static str = "nd";
216    pub const AGENT_REQ: &'static str = "grq";
217    pub const AGENT_RESP: &'static str = "grp";
218    pub const AGENT_DATA: &'static str = "gd";
219    pub const CLOUD_REQ: &'static str = "crq";
220    pub const CLOUD_RESP: &'static str = "crp";
221    pub const CLOUD_DATA: &'static str = "cd";
222    pub const APP_REQ: &'static str = "arq";
223    pub const APP_RESP: &'static str = "arp";
224    pub const SERVER_REQ: &'static str = "srq";
225    pub const SERVER_RESP: &'static str = "srp";
226    pub const APP_DATA: &'static str = "ad";
227    pub const SERVER_DATA: &'static str = "sd";
228    pub const PING: &'static str = "pi";
229    pub const PONG: &'static str = "po";
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    pub hub_id: String,
519    pub token: String,
520    pub node_type: String,
521    #[serde(default)]
522    pub node_id: String,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub scope_id: Option<String>,
525    pub host_id: String,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub host_name: Option<String>,
528    pub fingerprint: String,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct NodeAuthResponse {
534    pub ok: bool,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub session_id: Option<String>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub hub_id: Option<String>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub tenant_id: Option<String>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub scope_id: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub error: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub error_code: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub recovery_action: Option<String>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub node_id: Option<String>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize)]
554#[serde(rename_all = "camelCase")]
555pub struct NodeTokenIssueRequest {
556    pub transaction_id: String,
557    pub node_type: String,
558    pub candidate_host_id: String,
559    pub candidate_fingerprint: String,
560    pub challenge: NodeChallengeEvidence,
561}
562
563#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
564#[serde(rename_all = "camelCase")]
565pub enum NodeInstancePolicy {
566    Multiple,
567    Singleton,
568    Shared,
569}
570
571impl NodeInstancePolicy {
572    pub fn as_str(self) -> &'static str {
573        match self {
574            Self::Multiple => "multiple",
575            Self::Singleton => "singleton",
576            Self::Shared => "shared",
577        }
578    }
579}
580
581impl Default for NodeInstancePolicy {
582    fn default() -> Self {
583        Self::Multiple
584    }
585}
586
587impl std::fmt::Display for NodeInstancePolicy {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        f.write_str(self.as_str())
590    }
591}
592
593impl FromStr for NodeInstancePolicy {
594    type Err = String;
595
596    fn from_str(value: &str) -> Result<Self, Self::Err> {
597        match value.trim() {
598            "multiple" | "Multiple" => Ok(Self::Multiple),
599            "singleton" | "Singleton" => Ok(Self::Singleton),
600            "shared" | "Shared" => Ok(Self::Shared),
601            other => Err(format!("unsupported node instance policy: {other}")),
602        }
603    }
604}
605
606#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
607#[serde(rename_all = "camelCase")]
608pub enum NodeBindingStatus {
609    Pending,
610    Active,
611    Revoked,
612    Expired,
613    Failed,
614}
615
616impl NodeBindingStatus {
617    pub fn as_str(self) -> &'static str {
618        match self {
619            Self::Pending => "pending",
620            Self::Active => "active",
621            Self::Revoked => "revoked",
622            Self::Expired => "expired",
623            Self::Failed => "failed",
624        }
625    }
626}
627
628impl Default for NodeBindingStatus {
629    fn default() -> Self {
630        Self::Active
631    }
632}
633
634impl std::fmt::Display for NodeBindingStatus {
635    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636        f.write_str(self.as_str())
637    }
638}
639
640impl FromStr for NodeBindingStatus {
641    type Err = String;
642
643    fn from_str(value: &str) -> Result<Self, Self::Err> {
644        match value.trim() {
645            "pending" | "Pending" => Ok(Self::Pending),
646            "active" | "Active" => Ok(Self::Active),
647            "revoked" | "Revoked" => Ok(Self::Revoked),
648            "expired" | "Expired" => Ok(Self::Expired),
649            "failed" | "Failed" => Ok(Self::Failed),
650            other => Err(format!("unsupported node binding status: {other}")),
651        }
652    }
653}
654
655pub const NODE_ONBOARDING_CHALLENGE_PROTOCOL: &str = "meow.node.onboarding.challenge";
656pub const NODE_ONBOARDING_CHALLENGE_AUDIENCE: &str = "meow-core:node-onboarding";
657pub const NODE_ONBOARDING_CHALLENGE_ALGORITHM: &str = "Ed25519";
658pub const NODE_ONBOARDING_TOKEN_ISSUER_PREFIX: &str = "meow-core:hub:";
659pub const NODE_ONBOARDING_TOKEN_AUDIENCE: &str = "meow-node:onboarding";
660pub const NODE_ONBOARDING_ES256_ALGORITHM: &str = "ES256";
661pub const NODE_ONBOARDING_TRANSACTION_TTL_SECONDS: u64 = 5 * 60;
662pub const NODE_ONBOARDING_START_TARGET: &str = "/identity/node/onboarding/start";
663pub const NODE_TOKEN_ISSUE_TARGET: &str = "/identity/node/issue";
664pub const NODE_TOKEN_REVOKE_TARGET: &str = "/identity/node/revoke";
665pub const NODE_TOKEN_LIST_TARGET: &str = "/identity/node/list";
666pub const HUB_CONNECTION_PROOF_TARGET: &str = "/identity/hub/prove";
667pub const HUB_CONNECTION_PROOF_PROTOCOL: &str = "meow.hub.connection.proof.v1";
668
669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
670#[serde(rename_all = "camelCase", deny_unknown_fields)]
671pub struct NodeOnboardingStartRequest {
672    pub node_type: String,
673    pub candidate_host_id: String,
674    pub candidate_fingerprint: String,
675    pub idempotency_key: String,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
679#[serde(rename_all = "camelCase", deny_unknown_fields)]
680pub struct NodeOnboardingStartResponse {
681    pub transaction_id: String,
682    pub nonce: String,
683    pub expires_at: i64,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
687#[serde(rename_all = "camelCase")]
688pub struct HubConnectionProofRequest {
689    pub protocol: String,
690    pub hub_id: String,
691    pub tenant_id: String,
692    pub scope_id: String,
693    pub nonce: String,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
697#[serde(rename_all = "camelCase")]
698pub struct HubConnectionProofResponse {
699    pub protocol: String,
700    pub hub_id: String,
701    pub tenant_id: String,
702    pub scope_id: String,
703    pub nonce: String,
704    pub key_id: String,
705    pub signature: String,
706}
707
708/// Canonical, domain-separated bytes signed by the Hub for one physical WS connection.
709/// Length prefixes keep the encoding unambiguous without relying on JSON object ordering.
710pub fn hub_connection_proof_signing_payload(request: &HubConnectionProofRequest) -> Vec<u8> {
711    let fields = [
712        request.protocol.as_str(),
713        request.hub_id.as_str(),
714        request.tenant_id.as_str(),
715        request.scope_id.as_str(),
716        request.nonce.as_str(),
717    ];
718    let mut payload = Vec::new();
719    for field in fields {
720        payload.extend_from_slice(&(field.len() as u64).to_be_bytes());
721        payload.extend_from_slice(field.as_bytes());
722    }
723    payload
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
727#[serde(rename_all = "camelCase")]
728pub struct NodeChallengeEvidence {
729    pub protocol: String,
730    pub algorithm: String,
731    pub payload: String,
732    pub signature: String,
733    pub public_key: String,
734    pub fingerprint: String,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(rename_all = "camelCase")]
739pub struct NodeChallengePayload {
740    pub protocol: String,
741    pub aud: String,
742    pub nonce: String,
743    pub scope_id: String,
744    pub node_type: String,
745    pub host_id: String,
746    pub fingerprint: String,
747    pub service_instance_id: String,
748    pub instance_policy: NodeInstancePolicy,
749    pub instance_slot: String,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
753#[serde(rename_all = "camelCase")]
754pub struct NodeTokenIssueResponse {
755    pub token: String,
756    pub node_id: String,
757    pub expires_in: i64,
758    pub hub_id: String,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize)]
762#[serde(rename_all = "camelCase")]
763pub struct NodeTokenRevokeRequest {
764    pub node_type: String,
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    pub node_id: Option<String>,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770#[serde(rename_all = "camelCase")]
771pub struct NodeTokenRevokeResponse {
772    pub success: bool,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(rename_all = "camelCase")]
777pub struct NodeTokenListItem {
778    pub node_id: String,
779    pub node_type: String,
780    pub hub_id: String,
781    pub host_id: String,
782    pub fingerprint: String,
783    pub service_instance_id: String,
784    pub instance_policy: NodeInstancePolicy,
785    pub instance_slot: String,
786    pub status: NodeBindingStatus,
787    pub issued_at: i64,
788    pub expires_at: i64,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
792#[serde(rename_all = "camelCase")]
793pub struct NodeTokenListResponse {
794    #[serde(default)]
795    pub nodes: Vec<NodeTokenListItem>,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
799#[serde(rename_all = "camelCase")]
800pub struct NodeInstanceListItem {
801    pub node_id: String,
802    pub node_type: String,
803    pub hub_id: String,
804    pub host_id: String,
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub host_name: Option<String>,
807    pub fingerprint: String,
808    pub service_instance_id: String,
809    pub instance_policy: NodeInstancePolicy,
810    pub instance_slot: String,
811    pub binding_status: NodeBindingStatus,
812    pub issued_at: i64,
813    pub expires_at: i64,
814    pub connected: bool,
815    pub runtime_status: String,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
819#[serde(rename_all = "camelCase")]
820pub struct NodeInstanceListResponse {
821    #[serde(default)]
822    pub instances: Vec<NodeInstanceListItem>,
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
826#[serde(rename_all = "camelCase")]
827pub struct AuthenticatedSession {
828    pub tenant_id: String,
829    pub scope_id: String,
830    pub user_id: String,
831    pub is_test: bool,
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
835#[serde(rename_all = "snake_case")]
836pub enum ScopeAccessRequirement {
837    Member,
838    Owner,
839}
840
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "camelCase", deny_unknown_fields)]
843pub struct ScopeAuthorizationRequest {
844    pub tenant_id: String,
845    pub scope_id: String,
846    pub user_id: String,
847    pub requirement: ScopeAccessRequirement,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
851#[serde(rename_all = "camelCase", deny_unknown_fields)]
852pub struct ScopeMembershipListRequest {
853    pub tenant_id: String,
854    pub user_id: String,
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
858#[serde(rename_all = "camelCase", deny_unknown_fields)]
859pub struct ScopeMembership {
860    pub tenant_id: String,
861    pub scope_id: String,
862    pub user_id: String,
863}
864
865/// A service-owned AppClient materialized from an authoritative external model.
866/// This boundary intentionally carries no acting user: authorization belongs to
867/// the service that owns the source model, while Core validates the projection.
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869#[serde(rename_all = "camelCase", deny_unknown_fields)]
870pub struct ServiceAppClientProjection {
871    pub tenant_id: String,
872    pub scope_id: String,
873    pub app_client_id: String,
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub user_id: Option<String>,
876    pub source: String,
877    pub device_type: String,
878    #[serde(default)]
879    pub scope_owned: bool,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883#[serde(rename_all = "camelCase", deny_unknown_fields)]
884pub struct ServiceAppClientProjectionKey {
885    pub tenant_id: String,
886    pub scope_id: String,
887    pub app_client_id: String,
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub enum ServiceCoreInput {
892    ClientAuth {
893        message: MwsMessage,
894        ws_id: String,
895        scope_id: String,
896    },
897    ClientRequest {
898        target: String,
899        payload: String,
900        ws_id: String,
901        tenant_id: String,
902        scope_id: String,
903        user_id: String,
904        is_test: bool,
905        #[serde(default, skip_serializing_if = "Option::is_none")]
906        surface_id: Option<String>,
907    },
908    /// Invokes the canonical Conversation application boundary without a
909    /// websocket/client transport identity.
910    ConversationRequest {
911        target: String,
912        payload: String,
913        tenant_id: String,
914        scope_id: String,
915        actor_user_id: String,
916        surface_id: String,
917        is_test: bool,
918    },
919    /// Authorizes a trusted headless actor before a non-Conversation operation
920    /// such as binding a shared messaging surface.
921    ScopeAuthorization {
922        request: ScopeAuthorizationRequest,
923    },
924    /// Lists authoritative scope memberships for a trusted headless principal.
925    ScopeMembershipList {
926        request: ScopeMembershipListRequest,
927    },
928    /// Idempotently materializes a trusted service-owned AppClient.
929    AppClientProjectionPut {
930        projection: ServiceAppClientProjection,
931    },
932    /// Idempotently removes a trusted service-owned AppClient.
933    AppClientProjectionDelete {
934        key: ServiceAppClientProjectionKey,
935    },
936    NodeAuth {
937        request: NodeAuthRequest,
938    },
939    HubConnectionProof {
940        request: HubConnectionProofRequest,
941    },
942    NodeRequest {
943        target: String,
944        payload: String,
945        tenant_id: String,
946        scope_id: String,
947        node_type: String,
948        node_id: String,
949    },
950    /// Reports status from an already authenticated Node transport. Core uses
951    /// `connection_key` to bind the untrusted payload to the authoritative
952    /// Node session before accepting it into the Hub-owned status projection.
953    NodeStatusObserved {
954        connection_key: String,
955        status: Box<node::status::StatusPayload>,
956    },
957    ClientResponse {
958        message: MwsMessage,
959    },
960    NodeResponse {
961        message: MwsMessage,
962    },
963    IssueLocalSessionToken {
964        tenant_id: String,
965        scope_id: String,
966        user_id: String,
967        app_client_id: String,
968    },
969    SetLocalAppClientFocus {
970        ws_id: String,
971        focused: bool,
972    },
973    RefreshLocalAppClient {
974        ws_id: String,
975    },
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize)]
979pub enum ServiceCoreResponse {
980    ClientAuth {
981        session: Option<AuthenticatedSession>,
982        response: MwsMessage,
983    },
984    ClientRequest {
985        response_payload: Option<String>,
986        client_info: MwsClientInfo,
987    },
988    ConversationRequest {
989        response_payload: Option<String>,
990    },
991    ScopeAuthorization {
992        authorized: bool,
993    },
994    ScopeMembershipList {
995        memberships: Vec<ScopeMembership>,
996    },
997    AppClientProjection {
998        applied: bool,
999    },
1000    NodeAuth(NodeAuthResponse),
1001    HubConnectionProof(HubConnectionProofResponse),
1002    NodeRequest {
1003        response_payload: Option<String>,
1004    },
1005    LocalSessionToken(String),
1006    Ack {
1007        handled: bool,
1008    },
1009}
1010
1011#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1012#[serde(deny_unknown_fields)]
1013pub struct ServiceCoreOutput {
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    pub response: Option<ServiceCoreResponse>,
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct UserSetting {
1021    #[serde(default)]
1022    pub script_mode: bool,
1023    #[serde(default)]
1024    pub debug_mode: bool,
1025    #[serde(default)]
1026    pub eng_account: bool,
1027}
1028
1029impl UserSetting {
1030    pub fn new() -> Self {
1031        Self {
1032            script_mode: false,
1033            debug_mode: false,
1034            eng_account: false,
1035        }
1036    }
1037}
1038
1039impl Default for UserSetting {
1040    fn default() -> Self {
1041        Self::new()
1042    }
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize)]
1046#[serde(rename_all = "camelCase")]
1047pub struct UserInfo {
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    pub id: Option<String>,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub name: Option<String>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    pub email: Option<String>,
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub setting: Option<UserSetting>,
1056    #[serde(default)]
1057    pub eng: bool,
1058}
1059
1060impl UserInfo {
1061    pub fn new(id: String) -> Self {
1062        Self {
1063            id: Some(id),
1064            name: None,
1065            email: None,
1066            setting: None,
1067            eng: false,
1068        }
1069    }
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1073#[serde(rename_all = "camelCase")]
1074pub struct ScopeMember {
1075    pub id: Option<String>,
1076    pub email: Option<String>,
1077    pub name: Option<String>,
1078    pub pending: bool,
1079    pub role: Option<String>,
1080}
1081
1082impl Default for ScopeMember {
1083    fn default() -> Self {
1084        Self {
1085            id: None,
1086            email: None,
1087            name: None,
1088            pending: false,
1089            role: None,
1090        }
1091    }
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize)]
1095#[serde(rename_all = "camelCase")]
1096pub struct ScopeInfo {
1097    pub name: Option<String>,
1098    pub id: Option<String>,
1099    pub pending: bool,
1100    pub members: Option<Vec<ScopeMember>>,
1101    pub execution_env: Option<String>,
1102    pub mode: Option<String>,
1103    pub connection_mode: Option<String>,
1104    pub agent_mode: Option<String>,
1105    pub default_active: bool,
1106    #[serde(default)]
1107    pub is_test: bool,
1108}
1109
1110impl Default for ScopeInfo {
1111    fn default() -> Self {
1112        Self {
1113            name: None,
1114            id: None,
1115            pending: false,
1116            members: None,
1117            execution_env: None,
1118            mode: None,
1119            connection_mode: None,
1120            agent_mode: None,
1121            default_active: false,
1122            is_test: false,
1123        }
1124    }
1125}
1126
1127#[derive(Debug, Clone, Serialize, Deserialize)]
1128#[serde(rename_all = "camelCase")]
1129pub struct AuthConfig {
1130    pub tenant_id: String,
1131    pub heartbeat_interval: i32,
1132    pub command_timeout: i32,
1133    pub user_info: UserInfo,
1134    pub scope: ScopeInfo,
1135    #[serde(skip_serializing_if = "Option::is_none")]
1136    pub hub_id: Option<String>,
1137    #[serde(skip_serializing_if = "Option::is_none")]
1138    pub jwt_token: Option<String>,
1139}
1140
1141impl AuthConfig {
1142    pub fn new(
1143        tenant_id: String,
1144        heartbeat_interval: i32,
1145        command_timeout: i32,
1146        user_info: UserInfo,
1147        scope: ScopeInfo,
1148    ) -> Self {
1149        Self {
1150            tenant_id,
1151            heartbeat_interval,
1152            command_timeout,
1153            user_info,
1154            scope,
1155            hub_id: None,
1156            jwt_token: None,
1157        }
1158    }
1159}
1160
1161impl Default for AuthConfig {
1162    fn default() -> Self {
1163        Self {
1164            tenant_id: String::new(),
1165            heartbeat_interval: 240,
1166            command_timeout: 10,
1167            user_info: UserInfo::new(String::new()),
1168            scope: ScopeInfo::default(),
1169            hub_id: None,
1170            jwt_token: None,
1171        }
1172    }
1173}
1174
1175#[derive(Debug, Clone, Serialize, Deserialize)]
1176#[serde(rename_all = "camelCase")]
1177pub struct AuthRequest {
1178    pub token: String,
1179    pub source: String,
1180    pub scope_id: String,
1181    pub device_id: String,
1182    pub client_source: String,
1183    #[serde(default)]
1184    pub tenant_id: Option<String>,
1185    #[serde(default)]
1186    pub hub_id: Option<String>,
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize)]
1190#[serde(rename_all = "camelCase")]
1191pub struct HubMdnsInstanceRecord {
1192    pub tenant_id: String,
1193    pub scope_id: String,
1194    pub hub_id: String,
1195    pub scope_name: String,
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::{
1201        hub_connection_proof_signing_payload, HubConnectionProofRequest, MwsMessage,
1202        MwsMessageType, NodeOnboardingStartRequest, HUB_CONNECTION_PROOF_PROTOCOL,
1203    };
1204
1205    #[test]
1206    fn hub_connection_proof_payload_is_unambiguous_and_nonce_bound() {
1207        let request = HubConnectionProofRequest {
1208            protocol: HUB_CONNECTION_PROOF_PROTOCOL.to_string(),
1209            hub_id: "hub-1".to_string(),
1210            tenant_id: "tenant-1".to_string(),
1211            scope_id: "scope-1".to_string(),
1212            nonce: "nonce-1".to_string(),
1213        };
1214        let payload = hub_connection_proof_signing_payload(&request);
1215        let mut changed = request.clone();
1216        changed.nonce = "nonce-2".to_string();
1217
1218        assert_ne!(payload, hub_connection_proof_signing_payload(&changed));
1219        assert!(payload.starts_with(&(HUB_CONNECTION_PROOF_PROTOCOL.len() as u64).to_be_bytes()));
1220    }
1221
1222    #[test]
1223    fn scoped_server_data_builds_scope_envelope() {
1224        let message = MwsMessage::scoped_server_data(
1225            "scope-1".to_string(),
1226            "/dialog".to_string(),
1227            "{}".to_string(),
1228            None,
1229        );
1230
1231        assert_eq!(message.scope_id.as_deref(), Some("scope-1"));
1232        assert_eq!(message.target.as_deref(), Some("/dialog"));
1233        assert_eq!(message.r#type.as_deref(), Some(MwsMessageType::SERVER_DATA));
1234        assert_eq!(message.payload.as_deref(), Some("{}"));
1235        assert!(message.client_info.is_none());
1236    }
1237
1238    #[test]
1239    fn node_onboarding_start_contract_uses_camel_case_and_rejects_unknown_fields() {
1240        let request = NodeOnboardingStartRequest {
1241            node_type: "matter".to_string(),
1242            candidate_host_id: "host-1".to_string(),
1243            candidate_fingerprint: "sha256:abc".to_string(),
1244            idempotency_key: "attempt-1".to_string(),
1245        };
1246
1247        let json = serde_json::to_value(&request).expect("serialize start request");
1248        assert_eq!(json["candidateHostId"], "host-1");
1249        assert_eq!(json["idempotencyKey"], "attempt-1");
1250
1251        let invalid = serde_json::json!({
1252            "nodeType": "matter",
1253            "candidateHostId": "host-1",
1254            "candidateFingerprint": "sha256:abc",
1255            "idempotencyKey": "attempt-1",
1256            "nonce": "caller-must-not-supply-this"
1257        });
1258        assert!(serde_json::from_value::<NodeOnboardingStartRequest>(invalid).is_err());
1259    }
1260}