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